This commit is contained in:
alorig
2025-12-25 11:02:28 +05:00
18 changed files with 4164 additions and 559 deletions

View File

@@ -43,33 +43,21 @@ class AICore:
self._load_account_settings()
def _load_account_settings(self):
"""Load API keys from IntegrationSettings for account only - no fallbacks"""
def get_integration_key(integration_type: str, account):
if not account:
return None
try:
from igny8_core.modules.system.models import IntegrationSettings
settings_obj = IntegrationSettings.objects.filter(
integration_type=integration_type,
account=account,
is_active=True
).first()
if settings_obj and settings_obj.config:
return settings_obj.config.get('apiKey')
except Exception as e:
logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True)
return None
# Load account-specific keys only - configure via Django admin
if self.account:
self._openai_api_key = get_integration_key('openai', self.account)
self._runware_api_key = get_integration_key('runware', self.account)
# Fallback to Django settings as last resort
if not self._openai_api_key:
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
if not self._runware_api_key:
self._runware_api_key = getattr(settings, 'RUNWARE_API_KEY', None)
"""Load API keys from GlobalIntegrationSettings (platform-wide, used by ALL accounts)"""
try:
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Get global settings - single instance used by ALL accounts
global_settings = GlobalIntegrationSettings.get_instance()
# Load API keys from global settings (platform-wide)
self._openai_api_key = global_settings.openai_api_key
self._runware_api_key = global_settings.runware_api_key
except Exception as e:
logger.error(f"Could not load GlobalIntegrationSettings: {e}", exc_info=True)
self._openai_api_key = None
self._runware_api_key = None
def get_api_key(self, integration_type: str = 'openai') -> Optional[str]:
"""Get API key for integration type"""

View File

@@ -197,12 +197,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
prompt_text = str(prompt_data)
caption_text = ''
heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx + 1}"
heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx}"
Images.objects.update_or_create(
content=content,
image_type='in_article',
position=idx + 1,
position=idx, # 0-based position matching section array indices
defaults={
'prompt': prompt_text,
'caption': caption_text,
@@ -218,27 +218,33 @@ class GenerateImagePromptsFunction(BaseAIFunction):
# Helper methods
def _get_max_in_article_images(self, account) -> int:
"""Get max_in_article_images from AWS account IntegrationSettings only"""
"""
Get max_in_article_images from settings.
Uses account's IntegrationSettings override, or GlobalIntegrationSettings.
"""
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.auth.models import Account
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Only use system account (aws-admin) settings
system_account = Account.objects.get(slug='aws-admin')
settings = IntegrationSettings.objects.get(
account=system_account,
integration_type='image_generation',
is_active=True
)
max_images = settings.config.get('max_in_article_images')
if max_images is None:
raise ValueError(
"max_in_article_images not configured in aws-admin image_generation settings. "
"Please set this value in the Integration Settings page."
# Try account-specific override first
try:
settings = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
max_images = settings.config.get('max_in_article_images')
if max_images is not None:
max_images = int(max_images)
logger.info(f"Using max_in_article_images={max_images} from account {account.id} IntegrationSettings override")
return max_images
except IntegrationSettings.DoesNotExist:
logger.debug(f"No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
max_images = int(max_images)
logger.info(f"Using max_in_article_images={max_images} from aws-admin account")
# Use GlobalIntegrationSettings default
global_settings = GlobalIntegrationSettings.get_instance()
max_images = global_settings.max_in_article_images
logger.info(f"Using max_in_article_images={max_images} from GlobalIntegrationSettings (account {account.id})")
return max_images
def _extract_content_elements(self, content: Content, max_images: int) -> Dict:

View File

@@ -67,32 +67,40 @@ class GenerateImagesFunction(BaseAIFunction):
if not tasks:
raise ValueError("No tasks found")
# Get image generation settings from aws-admin account only (global settings)
# Get image generation settings
# Try account-specific override, otherwise use GlobalIntegrationSettings
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.auth.models import Account
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
system_account = Account.objects.get(slug='aws-admin')
integration = IntegrationSettings.objects.get(
account=system_account,
integration_type='image_generation',
is_active=True
)
image_settings = integration.config or {}
image_settings = {}
try:
integration = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
image_settings = integration.config or {}
logger.info(f"Using image settings from account {account.id} IntegrationSettings override")
except IntegrationSettings.DoesNotExist:
logger.info(f"No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
# Extract settings with defaults
provider = image_settings.get('provider') or image_settings.get('service', 'openai')
# Use GlobalIntegrationSettings for missing values
global_settings = GlobalIntegrationSettings.get_instance()
# Extract settings with defaults from global settings
provider = image_settings.get('provider') or image_settings.get('service') or global_settings.default_image_service
if provider == 'runware':
model = image_settings.get('model') or image_settings.get('runwareModel', 'runware:97@1')
model = image_settings.get('model') or image_settings.get('runwareModel') or global_settings.runware_model
else:
model = image_settings.get('model', 'dall-e-3')
model = image_settings.get('model') or global_settings.dalle_model
return {
'tasks': tasks,
'account': account,
'provider': provider,
'model': model,
'image_type': image_settings.get('image_type', 'realistic'),
'max_in_article_images': int(image_settings.get('max_in_article_images')),
'image_type': image_settings.get('image_type') or global_settings.image_style,
'max_in_article_images': int(image_settings.get('max_in_article_images') or global_settings.max_in_article_images),
'desktop_enabled': image_settings.get('desktop_enabled', True),
'mobile_enabled': image_settings.get('mobile_enabled', True),
}

View File

@@ -181,41 +181,47 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
failed = 0
results = []
# Get image generation settings from IntegrationSettings
# Always use system account settings (aws-admin) for global configuration
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings from aws-admin")
from igny8_core.auth.models import Account
# Get image generation settings
# Try account-specific override, otherwise use GlobalIntegrationSettings
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
config = {}
try:
system_account = Account.objects.get(slug='aws-admin')
image_settings = IntegrationSettings.objects.get(
account=system_account,
account=account,
integration_type='image_generation',
is_active=True
)
logger.info(f"[process_image_generation_queue] Using system account (aws-admin) settings")
logger.info(f"[process_image_generation_queue] Using account {account.id} IntegrationSettings override")
config = image_settings.config or {}
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
logger.error("[process_image_generation_queue] ERROR: Image generation settings not found in aws-admin account")
return {'success': False, 'error': 'Image generation settings not found in aws-admin account'}
except IntegrationSettings.DoesNotExist:
logger.info(f"[process_image_generation_queue] No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
except Exception as e:
logger.error(f"[process_image_generation_queue] ERROR loading image generation settings: {e}", exc_info=True)
return {'success': False, 'error': f'Error loading image generation settings: {str(e)}'}
# Use GlobalIntegrationSettings for missing values
global_settings = GlobalIntegrationSettings.get_instance()
logger.info(f"[process_image_generation_queue] Image generation settings loaded. Config keys: {list(config.keys())}")
logger.info(f"[process_image_generation_queue] Full config: {config}")
# Get provider and model from config (respect user settings)
provider = config.get('provider', 'openai')
# Get model - try 'model' first, then 'imageModel' as fallback
model = config.get('model') or config.get('imageModel') or 'dall-e-3'
# Get provider and model from config with global fallbacks
provider = config.get('provider') or global_settings.default_image_service
if provider == 'runware':
model = config.get('model') or config.get('imageModel') or global_settings.runware_model
else:
model = config.get('model') or config.get('imageModel') or global_settings.dalle_model
logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings")
image_type = config.get('image_type', 'realistic')
image_type = config.get('image_type') or global_settings.image_style
image_format = config.get('image_format', 'webp')
desktop_enabled = config.get('desktop_enabled', True)
mobile_enabled = config.get('mobile_enabled', True)
# Get image sizes from config, with fallback defaults
featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024')
desktop_image_size = config.get('desktop_image_size') or '1024x1024'
desktop_image_size = config.get('desktop_image_size') or global_settings.desktop_image_size
in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512
logger.info(f"[process_image_generation_queue] Settings loaded:")
@@ -226,44 +232,22 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info(f" - Desktop enabled: {desktop_enabled}")
logger.info(f" - Mobile enabled: {mobile_enabled}")
# Get provider API key (using same approach as test image generation)
# Note: API key is stored as 'apiKey' (camelCase) in IntegrationSettings.config
# Normal users use system account settings (aws-admin) via fallback
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key")
try:
provider_settings = IntegrationSettings.objects.get(
account=account,
integration_type=provider, # Use the provider from settings
is_active=True
)
logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found for account {account.id}")
except IntegrationSettings.DoesNotExist:
# Fallback to system account (aws-admin) settings
logger.info(f"[process_image_generation_queue] No {provider.upper()} settings for account {account.id}, falling back to system account")
from igny8_core.auth.models import Account
try:
system_account = Account.objects.get(slug='aws-admin')
provider_settings = IntegrationSettings.objects.get(
account=system_account,
integration_type=provider,
is_active=True
)
logger.info(f"[process_image_generation_queue] Using system account (aws-admin) {provider.upper()} settings")
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found in system account either")
return {'success': False, 'error': f'{provider.upper()} integration not found or not active'}
except Exception as e:
logger.error(f"[process_image_generation_queue] ERROR getting {provider.upper()} API key: {e}", exc_info=True)
return {'success': False, 'error': f'Error retrieving {provider.upper()} API key: {str(e)}'}
# Get provider API key
# API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys)
# Account IntegrationSettings only store provider preference, NOT API keys
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key from GlobalIntegrationSettings")
# Extract API key from provider settings
logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}")
# Get API key from GlobalIntegrationSettings
if provider == 'runware':
api_key = global_settings.runware_api_key
elif provider == 'openai':
api_key = global_settings.dalle_api_key or global_settings.openai_api_key
else:
api_key = None
api_key = provider_settings.config.get('apiKey') if provider_settings.config else None
if not api_key:
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not found in config")
logger.error(f"[process_image_generation_queue] {provider.upper()} config: {provider_settings.config}")
return {'success': False, 'error': f'{provider.upper()} API key not configured'}
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not configured in GlobalIntegrationSettings")
return {'success': False, 'error': f'{provider.upper()} API key not configured in GlobalIntegrationSettings'}
# Log API key presence (but not the actual key for security)
api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***"

View File

@@ -5,6 +5,7 @@ import time
import logging
from typing import List, Dict, Any, Optional, Callable
from datetime import datetime
from decimal import Decimal
from igny8_core.ai.constants import DEBUG_MODE
logger = logging.getLogger(__name__)
@@ -195,24 +196,35 @@ class CostTracker:
"""Tracks API costs and token usage"""
def __init__(self):
self.total_cost = 0.0
self.total_cost = Decimal('0.0')
self.total_tokens = 0
self.operations = []
def record(self, function_name: str, cost: float, tokens: int, model: str = None):
"""Record an API call cost"""
def record(self, function_name: str, cost, tokens: int, model: str = None):
"""Record an API call cost
Args:
function_name: Name of the AI function
cost: Cost value (can be float or Decimal)
tokens: Number of tokens used
model: Model name
"""
# Convert cost to Decimal if it's a float to avoid type mixing
if not isinstance(cost, Decimal):
cost = Decimal(str(cost))
self.total_cost += cost
self.total_tokens += tokens
self.operations.append({
'function': function_name,
'cost': cost,
'cost': float(cost), # Store as float for JSON serialization
'tokens': tokens,
'model': model
})
def get_total(self) -> float:
"""Get total cost"""
return self.total_cost
def get_total(self):
"""Get total cost (returns float for JSON serialization)"""
return float(self.total_cost)
def get_total_tokens(self) -> int:
"""Get total tokens"""

View File

@@ -135,7 +135,7 @@ def validate_api_key(api_key: Optional[str], integration_type: str = 'openai') -
def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
"""
Validate that model is in supported list.
Validate that model is in supported list using database.
Args:
model: Model name to validate
@@ -144,27 +144,59 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
Returns:
Dict with 'valid' (bool) and optional 'error' (str)
"""
from .constants import MODEL_RATES, VALID_OPENAI_IMAGE_MODELS
if model_type == 'text':
if model not in MODEL_RATES:
return {
'valid': False,
'error': f'Model "{model}" is not in supported models list'
}
elif model_type == 'image':
if model not in VALID_OPENAI_IMAGE_MODELS:
return {
'valid': False,
'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.'
}
return {'valid': True}
try:
# Try database first
from igny8_core.business.billing.models import AIModelConfig
exists = AIModelConfig.objects.filter(
model_name=model,
model_type=model_type,
is_active=True
).exists()
if not exists:
# Get available models for better error message
available = list(AIModelConfig.objects.filter(
model_type=model_type,
is_active=True
).values_list('model_name', flat=True))
if available:
return {
'valid': False,
'error': f'Model "{model}" is not active or not found. Available {model_type} models: {", ".join(available)}'
}
else:
return {
'valid': False,
'error': f'Model "{model}" is not found in database'
}
return {'valid': True}
except Exception:
# Fallback to constants if database fails
from .constants import MODEL_RATES, VALID_OPENAI_IMAGE_MODELS
if model_type == 'text':
if model not in MODEL_RATES:
return {
'valid': False,
'error': f'Model "{model}" is not in supported models list'
}
elif model_type == 'image':
if model not in VALID_OPENAI_IMAGE_MODELS:
return {
'valid': False,
'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.'
}
return {'valid': True}
def validate_image_size(size: str, model: str) -> Dict[str, Any]:
"""
Validate that image size is valid for the selected model.
Validate that image size is valid for the selected model using database.
Args:
size: Image size (e.g., '1024x1024')
@@ -173,14 +205,40 @@ def validate_image_size(size: str, model: str) -> Dict[str, Any]:
Returns:
Dict with 'valid' (bool) and optional 'error' (str)
"""
from .constants import VALID_SIZES_BY_MODEL
valid_sizes = VALID_SIZES_BY_MODEL.get(model, [])
if size not in valid_sizes:
return {
'valid': False,
'error': f'Image size "{size}" is not valid for model "{model}". Valid sizes are: {", ".join(valid_sizes)}'
}
return {'valid': True}
try:
# Try database first
from igny8_core.business.billing.models import AIModelConfig
model_config = AIModelConfig.objects.filter(
model_name=model,
model_type='image',
is_active=True
).first()
if model_config:
if not model_config.validate_size(size):
valid_sizes = model_config.valid_sizes or []
return {
'valid': False,
'error': f'Image size "{size}" is not valid for model "{model}". Valid sizes are: {", ".join(valid_sizes)}'
}
return {'valid': True}
else:
return {
'valid': False,
'error': f'Image model "{model}" not found in database'
}
except Exception:
# Fallback to constants if database fails
from .constants import VALID_SIZES_BY_MODEL
valid_sizes = VALID_SIZES_BY_MODEL.get(model, [])
if size not in valid_sizes:
return {
'valid': False,
'error': f'Image size "{size}" is not valid for model "{model}". Valid sizes are: {", ".join(valid_sizes)}'
}
return {'valid': True}

View File

@@ -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

View File

@@ -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()

View File

@@ -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')

View File

@@ -8,16 +8,17 @@ from unfold.admin import ModelAdmin
from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from igny8_core.business.billing.models import (
AIModelConfig,
CreditCostConfig,
BillingConfiguration,
Invoice,
Payment,
CreditPackage,
PaymentMethodConfig,
PlanLimitUsage,
AIModelConfig,
)
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
from rangefilter.filters import DateRangeFilter
@@ -50,43 +51,21 @@ class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
@admin.register(AIModelConfig)
class AIModelConfigAdmin(Igny8ModelAdmin):
list_display = ['display_name', 'model_name', 'provider', 'model_type', 'tokens_per_credit', 'cost_per_1k_input_tokens', 'cost_per_1k_output_tokens', 'is_active', 'is_default']
list_filter = ['provider', 'model_type', 'is_active', 'is_default']
search_fields = ['model_name', 'display_name', 'description']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Model Information', {
'fields': ('model_name', 'display_name', 'description', 'provider', 'model_type')
}),
('Pricing', {
'fields': ('cost_per_1k_input_tokens', 'cost_per_1k_output_tokens', 'tokens_per_credit')
}),
('Status', {
'fields': ('is_active', 'is_default')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def save_model(self, request, obj, form, change):
# If setting as default, unset other defaults of same type
if obj.is_default:
AIModelConfig.objects.filter(
model_type=obj.model_type,
is_default=True
).exclude(pk=obj.pk).update(is_default=False)
super().save_model(request, obj, form, change)
class CreditUsageLogResource(resources.ModelResource):
"""Resource class for exporting Credit Usage Logs"""
class Meta:
model = CreditUsageLog
fields = ('id', 'account__name', 'operation_type', 'credits_used', 'cost_usd',
'model_used', 'created_at')
export_order = fields
@admin.register(CreditUsageLog)
class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_config', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_config']
search_fields = ['account__name', 'model_name']
class CreditUsageLogAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = CreditUsageLogResource
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_used']
search_fields = ['account__name', 'model_used']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
@@ -100,8 +79,18 @@ class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin):
get_account_display.short_description = 'Account'
class InvoiceResource(resources.ModelResource):
"""Resource class for exporting Invoices"""
class Meta:
model = Invoice
fields = ('id', 'invoice_number', 'account__name', 'status', 'total', 'currency',
'invoice_date', 'due_date', 'created_at', 'updated_at')
export_order = fields
@admin.register(Invoice)
class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
class InvoiceAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = InvoiceResource
list_display = [
'invoice_number',
'account',
@@ -114,6 +103,56 @@ class InvoiceAdmin(AccountAdminMixin, Igny8ModelAdmin):
list_filter = ['status', 'currency', 'invoice_date', 'account']
search_fields = ['invoice_number', 'account__name']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_set_status_draft',
'bulk_set_status_sent',
'bulk_set_status_paid',
'bulk_set_status_overdue',
'bulk_set_status_cancelled',
'bulk_send_reminders',
]
def bulk_set_status_draft(self, request, queryset):
"""Set selected invoices to draft status"""
updated = queryset.update(status='draft')
self.message_user(request, f'{updated} invoice(s) set to draft.', messages.SUCCESS)
bulk_set_status_draft.short_description = 'Set status to Draft'
def bulk_set_status_sent(self, request, queryset):
"""Set selected invoices to sent status"""
updated = queryset.update(status='sent')
self.message_user(request, f'{updated} invoice(s) set to sent.', messages.SUCCESS)
bulk_set_status_sent.short_description = 'Set status to Sent'
def bulk_set_status_paid(self, request, queryset):
"""Set selected invoices to paid status"""
updated = queryset.update(status='paid')
self.message_user(request, f'{updated} invoice(s) set to paid.', messages.SUCCESS)
bulk_set_status_paid.short_description = 'Set status to Paid'
def bulk_set_status_overdue(self, request, queryset):
"""Set selected invoices to overdue status"""
updated = queryset.update(status='overdue')
self.message_user(request, f'{updated} invoice(s) set to overdue.', messages.SUCCESS)
bulk_set_status_overdue.short_description = 'Set status to Overdue'
def bulk_set_status_cancelled(self, request, queryset):
"""Set selected invoices to cancelled status"""
updated = queryset.update(status='cancelled')
self.message_user(request, f'{updated} invoice(s) set to cancelled.', messages.SUCCESS)
bulk_set_status_cancelled.short_description = 'Set status to Cancelled'
def bulk_send_reminders(self, request, queryset):
"""Send reminder emails for selected invoices"""
# TODO: Implement email sending logic when email service is configured
unpaid = queryset.filter(status__in=['sent', 'overdue'])
count = unpaid.count()
self.message_user(
request,
f'{count} invoice reminder(s) queued for sending. (Email integration required)',
messages.INFO
)
bulk_send_reminders.short_description = 'Send payment reminders'
class PaymentResource(resources.ModelResource):
@@ -160,7 +199,7 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'manual_notes'
]
readonly_fields = ['created_at', 'updated_at', 'approved_at', 'processed_at', 'failed_at', 'refunded_at']
actions = ['approve_payments', 'reject_payments']
actions = ['approve_payments', 'reject_payments', 'bulk_refund']
fieldsets = (
('Payment Info', {
@@ -406,14 +445,71 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
self.message_user(request, f'Rejected {count} payment(s)')
reject_payments.short_description = 'Reject selected manual payments'
def bulk_refund(self, request, queryset):
"""Refund selected payments"""
from django.utils import timezone
# Only refund succeeded payments
succeeded_payments = queryset.filter(status='succeeded')
count = 0
for payment in succeeded_payments:
# Mark as refunded
payment.status = 'refunded'
payment.refunded_at = timezone.now()
payment.admin_notes = f'{payment.admin_notes or ""}\nBulk refunded by {request.user.email} on {timezone.now()}'
payment.save()
# TODO: Process actual refund through payment gateway (Stripe/PayPal)
# For now, just marking as refunded in database
count += 1
self.message_user(
request,
f'{count} payment(s) marked as refunded. Note: Actual gateway refunds need to be processed separately.',
messages.WARNING
)
bulk_refund.short_description = 'Refund selected payments'
class CreditPackageResource(resources.ModelResource):
"""Resource class for importing/exporting Credit Packages"""
class Meta:
model = CreditPackage
fields = ('id', 'name', 'slug', 'credits', 'price', 'discount_percentage',
'is_active', 'is_featured', 'sort_order', 'created_at')
export_order = fields
import_id_fields = ('id',)
skip_unchanged = True
@admin.register(CreditPackage)
class CreditPackageAdmin(Igny8ModelAdmin):
class CreditPackageAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = CreditPackageResource
list_display = ['name', 'slug', 'credits', 'price', 'discount_percentage', 'is_active', 'is_featured', 'sort_order']
list_filter = ['is_active', 'is_featured']
search_fields = ['name', 'slug']
readonly_fields = ['created_at', 'updated_at']
actions = [
'bulk_activate',
'bulk_deactivate',
]
actions = [
'bulk_activate',
'bulk_deactivate',
]
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} credit package(s) activated.', messages.SUCCESS)
bulk_activate.short_description = 'Activate selected packages'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} credit package(s) deactivated.', messages.SUCCESS)
bulk_deactivate.short_description = 'Deactivate selected packages'
@admin.register(PaymentMethodConfig)
@@ -459,55 +555,57 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
list_display = [
'operation_type',
'display_name',
'credits_cost_display',
'unit',
'tokens_per_credit_display',
'price_per_credit_usd',
'min_credits',
'is_active',
'cost_change_indicator',
'updated_at',
'updated_by'
]
list_filter = ['is_active', 'unit', 'updated_at']
list_filter = ['is_active', 'updated_at']
search_fields = ['operation_type', 'display_name', 'description']
fieldsets = (
('Operation', {
'fields': ('operation_type', 'display_name', 'description')
}),
('Cost Configuration', {
'fields': ('credits_cost', 'unit', 'is_active')
('Token-to-Credit Configuration', {
'fields': ('tokens_per_credit', 'min_credits', 'price_per_credit_usd', 'is_active'),
'description': 'Configure how tokens are converted to credits for this operation'
}),
('Audit Trail', {
'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
'fields': ('previous_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at', 'previous_cost']
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
def credits_cost_display(self, obj):
"""Show cost with color coding"""
if obj.credits_cost >= 20:
color = 'red'
elif obj.credits_cost >= 10:
def tokens_per_credit_display(self, obj):
"""Show token ratio with color coding"""
if obj.tokens_per_credit <= 50:
color = 'red' # Expensive (low tokens per credit)
elif obj.tokens_per_credit <= 100:
color = 'orange'
else:
color = 'green'
color = 'green' # Cheap (high tokens per credit)
return format_html(
'<span style="color: {}; font-weight: bold;">{} credits</span>',
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
color,
obj.credits_cost
obj.tokens_per_credit
)
credits_cost_display.short_description = 'Cost'
tokens_per_credit_display.short_description = 'Token Ratio'
def cost_change_indicator(self, obj):
"""Show if cost changed recently"""
if obj.previous_cost is not None:
if obj.credits_cost > obj.previous_cost:
icon = '📈' # Increased
"""Show if token ratio changed recently"""
if obj.previous_tokens_per_credit is not None:
if obj.tokens_per_credit < obj.previous_tokens_per_credit:
icon = '📈' # More expensive (fewer tokens per credit)
color = 'red'
elif obj.credits_cost < obj.previous_cost:
icon = '📉' # Decreased
elif obj.tokens_per_credit > obj.previous_tokens_per_credit:
icon = '📉' # Cheaper (more tokens per credit)
color = 'green'
else:
icon = '➡️' # Same
@@ -517,8 +615,8 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'{} <span style="color: {};">({}{})</span>',
icon,
color,
obj.previous_cost,
obj.credits_cost
obj.previous_tokens_per_credit,
obj.tokens_per_credit
)
return ''
cost_change_indicator.short_description = 'Recent Change'
@@ -529,8 +627,18 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
super().save_model(request, obj, form, change)
class PlanLimitUsageResource(resources.ModelResource):
"""Resource class for exporting Plan Limit Usage"""
class Meta:
model = PlanLimitUsage
fields = ('id', 'account__name', 'limit_type', 'amount_used',
'period_start', 'period_end', 'created_at')
export_order = fields
@admin.register(PlanLimitUsage)
class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = PlanLimitUsageResource
"""Admin for tracking plan limit usage across billing periods"""
list_display = [
'account',
@@ -548,6 +656,10 @@ class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
search_fields = ['account__name']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'period_start'
actions = [
'bulk_reset_usage',
'bulk_delete_old_records',
]
fieldsets = (
('Usage Info', {
@@ -570,4 +682,272 @@ class PlanLimitUsageAdmin(AccountAdminMixin, Igny8ModelAdmin):
"""Display billing period range"""
return f"{obj.period_start} to {obj.period_end}"
period_display.short_description = 'Billing Period'
def bulk_reset_usage(self, request, queryset):
"""Reset usage counters to zero"""
updated = queryset.update(amount_used=0)
self.message_user(request, f'{updated} usage counter(s) reset to zero.', messages.SUCCESS)
bulk_reset_usage.short_description = 'Reset usage counters'
def bulk_delete_old_records(self, request, queryset):
"""Delete usage records older than 1 year"""
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=365)
old_records = queryset.filter(period_end__lt=cutoff_date)
count = old_records.count()
old_records.delete()
self.message_user(request, f'{count} old usage record(s) deleted (older than 1 year).', messages.SUCCESS)
bulk_delete_old_records.short_description = 'Delete old records (>1 year)'
@admin.register(BillingConfiguration)
class BillingConfigurationAdmin(Igny8ModelAdmin):
"""Admin for global billing configuration (Singleton)"""
list_display = [
'id',
'default_tokens_per_credit',
'default_credit_price_usd',
'credit_rounding_mode',
'enable_token_based_reporting',
'updated_at',
'updated_by'
]
fieldsets = (
('Global Token-to-Credit Settings', {
'fields': ('default_tokens_per_credit', 'default_credit_price_usd', 'credit_rounding_mode'),
'description': 'These settings apply when no operation-specific config exists'
}),
('Reporting Settings', {
'fields': ('enable_token_based_reporting',),
'description': 'Control token-based reporting features'
}),
('Audit Trail', {
'fields': ('updated_by', 'updated_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ['updated_at']
def has_add_permission(self, request):
"""Only allow one instance (singleton)"""
from igny8_core.business.billing.models import BillingConfiguration
return not BillingConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of the singleton"""
return False
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)
@admin.register(AIModelConfig)
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
"""
Admin for AI Model Configuration - Database-driven model pricing
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES
"""
list_display = [
'model_name',
'display_name_short',
'model_type_badge',
'provider_badge',
'pricing_display',
'is_active_icon',
'is_default_icon',
'sort_order',
'updated_at',
]
list_filter = [
'model_type',
'provider',
'is_active',
'is_default',
'supports_json_mode',
'supports_vision',
'supports_function_calling',
]
search_fields = ['model_name', 'display_name', 'description']
ordering = ['model_type', 'sort_order', 'model_name']
readonly_fields = ['created_at', 'updated_at', 'updated_by']
fieldsets = (
('Basic Information', {
'fields': ('model_name', 'display_name', 'model_type', 'provider', 'description'),
'description': 'Core model identification and classification'
}),
('Text Model Pricing', {
'fields': ('input_cost_per_1m', 'output_cost_per_1m', 'context_window', 'max_output_tokens'),
'description': 'Pricing and limits for TEXT models only (leave blank for image models)',
'classes': ('collapse',)
}),
('Image Model Pricing', {
'fields': ('cost_per_image', 'valid_sizes'),
'description': 'Pricing and configuration for IMAGE models only (leave blank for text models)',
'classes': ('collapse',)
}),
('Capabilities', {
'fields': ('supports_json_mode', 'supports_vision', 'supports_function_calling'),
'description': 'Model features and capabilities'
}),
('Status & Display', {
'fields': ('is_active', 'is_default', 'sort_order'),
'description': 'Control model availability and ordering in dropdowns'
}),
('Lifecycle', {
'fields': ('release_date', 'deprecation_date'),
'description': 'Model release and deprecation dates',
'classes': ('collapse',)
}),
('Audit Trail', {
'fields': ('created_at', 'updated_at', 'updated_by'),
'classes': ('collapse',)
}),
)
# Custom display methods
def display_name_short(self, obj):
"""Truncated display name for list view"""
if len(obj.display_name) > 50:
return obj.display_name[:47] + '...'
return obj.display_name
display_name_short.short_description = 'Display Name'
def model_type_badge(self, obj):
"""Colored badge for model type"""
colors = {
'text': '#3498db', # Blue
'image': '#e74c3c', # Red
'embedding': '#2ecc71', # Green
}
color = colors.get(obj.model_type, '#95a5a6')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; '
'border-radius: 3px; font-weight: bold;">{}</span>',
color,
obj.get_model_type_display()
)
model_type_badge.short_description = 'Type'
def provider_badge(self, obj):
"""Colored badge for provider"""
colors = {
'openai': '#10a37f', # OpenAI green
'anthropic': '#d97757', # Anthropic orange
'runware': '#6366f1', # Purple
'google': '#4285f4', # Google blue
}
color = colors.get(obj.provider, '#95a5a6')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; '
'border-radius: 3px; font-weight: bold;">{}</span>',
color,
obj.get_provider_display()
)
provider_badge.short_description = 'Provider'
def pricing_display(self, obj):
"""Format pricing based on model type"""
if obj.model_type == 'text':
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} / ${} per 1M</span>',
obj.input_cost_per_1m,
obj.output_cost_per_1m
)
elif obj.model_type == 'image':
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} per image</span>',
obj.cost_per_image
)
return '-'
pricing_display.short_description = 'Pricing'
def is_active_icon(self, obj):
"""Active status icon"""
if obj.is_active:
return format_html(
'<span style="color: green; font-size: 18px;" title="Active">●</span>'
)
return format_html(
'<span style="color: red; font-size: 18px;" title="Inactive">●</span>'
)
is_active_icon.short_description = 'Active'
def is_default_icon(self, obj):
"""Default status icon"""
if obj.is_default:
return format_html(
'<span style="color: gold; font-size: 18px;" title="Default">★</span>'
)
return format_html(
'<span style="color: #ddd; font-size: 18px;" title="Not Default">☆</span>'
)
is_default_icon.short_description = 'Default'
# Admin actions
actions = ['bulk_activate', 'bulk_deactivate', 'set_as_default']
def bulk_activate(self, request, queryset):
"""Enable selected models"""
count = queryset.update(is_active=True)
self.message_user(
request,
f'{count} model(s) activated successfully.',
messages.SUCCESS
)
bulk_activate.short_description = 'Activate selected models'
def bulk_deactivate(self, request, queryset):
"""Disable selected models"""
count = queryset.update(is_active=False)
self.message_user(
request,
f'{count} model(s) deactivated successfully.',
messages.WARNING
)
bulk_deactivate.short_description = 'Deactivate selected models'
def set_as_default(self, request, queryset):
"""Set one model as default for its type"""
if queryset.count() != 1:
self.message_user(
request,
'Please select exactly one model to set as default.',
messages.ERROR
)
return
model = queryset.first()
# Unset other defaults for same type
AIModelConfig.objects.filter(
model_type=model.model_type,
is_default=True
).exclude(pk=model.pk).update(is_default=False)
# Set this as default
model.is_default = True
model.save()
self.message_user(
request,
f'{model.model_name} is now the default {model.get_model_type_display()} model.',
messages.SUCCESS
)
set_as_default.short_description = 'Set as default model (for its type)'
def save_model(self, request, obj, form, change):
"""Track who made the change"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,264 @@
# Generated by Django 5.2.9 on 2025-12-24 01:20
import django.core.validators
import django.db.models.deletion
import simple_history.models
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
def seed_ai_models(apps, schema_editor):
"""Seed AIModelConfig with data from constants.py"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
# Text Models (from MODEL_RATES in constants.py)
text_models = [
{
'model_name': 'gpt-4o-mini',
'display_name': 'GPT-4o mini - Fast & Affordable',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('0.1500'),
'output_cost_per_1m': Decimal('0.6000'),
'context_window': 128000,
'max_output_tokens': 16000,
'supports_json_mode': True,
'supports_vision': False,
'supports_function_calling': True,
'is_active': True,
'is_default': True, # Default text model
'sort_order': 1,
'description': 'Fast and cost-effective model for most tasks. Best balance of speed and quality.',
},
{
'model_name': 'gpt-4.1',
'display_name': 'GPT-4.1 - Legacy Model',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('2.0000'),
'output_cost_per_1m': Decimal('8.0000'),
'context_window': 8192,
'max_output_tokens': 4096,
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': True,
'is_default': False,
'sort_order': 10,
'description': 'Legacy GPT-4 model. Higher cost but reliable.',
},
{
'model_name': 'gpt-4o',
'display_name': 'GPT-4o - High Quality with Vision',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('2.5000'),
'output_cost_per_1m': Decimal('10.0000'),
'context_window': 128000,
'max_output_tokens': 4096,
'supports_json_mode': True,
'supports_vision': True,
'supports_function_calling': True,
'is_active': True,
'is_default': False,
'sort_order': 5,
'description': 'Most capable GPT-4 variant with vision capabilities. Best for complex tasks.',
},
{
'model_name': 'gpt-5.1',
'display_name': 'GPT-5.1 - Advanced (16K context)',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('1.2500'),
'output_cost_per_1m': Decimal('10.0000'),
'context_window': 16000,
'max_output_tokens': 16000,
'supports_json_mode': True,
'supports_vision': False,
'supports_function_calling': True,
'is_active': True,
'is_default': False,
'sort_order': 20,
'description': 'Advanced GPT-5 model with 16K context window.',
},
{
'model_name': 'gpt-5.2',
'display_name': 'GPT-5.2 - Most Advanced (16K context)',
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal('1.7500'),
'output_cost_per_1m': Decimal('14.0000'),
'context_window': 16000,
'max_output_tokens': 16000,
'supports_json_mode': True,
'supports_vision': False,
'supports_function_calling': True,
'is_active': True,
'is_default': False,
'sort_order': 30,
'description': 'Most advanced GPT-5 variant. Highest quality output.',
},
]
# Image Models (from IMAGE_MODEL_RATES in constants.py)
image_models = [
{
'model_name': 'dall-e-3',
'display_name': 'DALL-E 3 - High Quality Images',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0400'),
'valid_sizes': ['1024x1024', '1024x1792', '1792x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': True,
'is_default': True, # Default image model
'sort_order': 1,
'description': 'Latest DALL-E model with best quality and prompt adherence.',
},
{
'model_name': 'dall-e-2',
'display_name': 'DALL-E 2 - Standard Quality',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0200'),
'valid_sizes': ['256x256', '512x512', '1024x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': True,
'is_default': False,
'sort_order': 10,
'description': 'Cost-effective image generation with good quality.',
},
{
'model_name': 'gpt-image-1',
'display_name': 'GPT Image 1 (Not compatible with OpenAI)',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0420'),
'valid_sizes': ['1024x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': False, # Not valid for OpenAI endpoint
'is_default': False,
'sort_order': 20,
'description': 'Not compatible with OpenAI /v1/images/generations endpoint.',
},
{
'model_name': 'gpt-image-1-mini',
'display_name': 'GPT Image 1 Mini (Not compatible with OpenAI)',
'model_type': 'image',
'provider': 'openai',
'cost_per_image': Decimal('0.0110'),
'valid_sizes': ['1024x1024'],
'supports_json_mode': False,
'supports_vision': False,
'supports_function_calling': False,
'is_active': False, # Not valid for OpenAI endpoint
'is_default': False,
'sort_order': 30,
'description': 'Not compatible with OpenAI /v1/images/generations endpoint.',
},
]
# Create all models
for model_data in text_models + image_models:
AIModelConfig.objects.create(**model_data)
def reverse_seed(apps, schema_editor):
"""Remove seeded data"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
AIModelConfig.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('billing', '0019_populate_token_based_config'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='HistoricalAIModelConfig',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('model_name', models.CharField(db_index=True, help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')", max_length=100)),
('display_name', models.CharField(help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')", max_length=200)),
('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embedding')], db_index=True, help_text='Type of model - determines which pricing fields are used', max_length=20)),
('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='AI provider (OpenAI, Anthropic, etc.)', max_length=50)),
('input_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million input tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('output_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million output tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('context_window', models.IntegerField(blank=True, help_text='Maximum input tokens (context length). For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('max_output_tokens', models.IntegerField(blank=True, help_text='Maximum output tokens per request. For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('cost_per_image', models.DecimalField(blank=True, decimal_places=4, help_text='Fixed cost per image generation (USD). For image models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('valid_sizes', models.JSONField(blank=True, help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.', null=True)),
('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')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Enable/disable model without deleting')),
('is_default', models.BooleanField(db_index=True, default=False, 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)')),
('description', models.TextField(blank=True, help_text='Admin notes about model usage, strengths, limitations')),
('release_date', models.DateField(blank=True, help_text='When model was released/added', null=True)),
('deprecation_date', models.DateField(blank=True, help_text='When model will be removed', null=True)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(blank=True, db_constraint=False, help_text='Admin who last updated', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical AI Model Configuration',
'verbose_name_plural': 'historical AI Model Configurations',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='AIModelConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('model_name', models.CharField(db_index=True, help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')", max_length=100, unique=True)),
('display_name', models.CharField(help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')", max_length=200)),
('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embedding')], db_index=True, help_text='Type of model - determines which pricing fields are used', max_length=20)),
('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='AI provider (OpenAI, Anthropic, etc.)', max_length=50)),
('input_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million input tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('output_cost_per_1m', models.DecimalField(blank=True, decimal_places=4, help_text='Cost per 1 million output tokens (USD). For text models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('context_window', models.IntegerField(blank=True, help_text='Maximum input tokens (context length). For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('max_output_tokens', models.IntegerField(blank=True, help_text='Maximum output tokens per request. For text models only.', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('cost_per_image', models.DecimalField(blank=True, decimal_places=4, help_text='Fixed cost per image generation (USD). For image models only.', max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))])),
('valid_sizes', models.JSONField(blank=True, help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.', null=True)),
('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')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Enable/disable model without deleting')),
('is_default', models.BooleanField(db_index=True, default=False, 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)')),
('description', models.TextField(blank=True, help_text='Admin notes about model usage, strengths, limitations')),
('release_date', models.DateField(blank=True, help_text='When model was released/added', null=True)),
('deprecation_date', models.DateField(blank=True, help_text='When model will be removed', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, help_text='Admin who last updated', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_model_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'AI Model Configuration',
'verbose_name_plural': 'AI Model Configurations',
'db_table': 'igny8_ai_model_config',
'ordering': ['model_type', 'sort_order', 'model_name'],
'indexes': [models.Index(fields=['model_type', 'is_active'], name='igny8_ai_mo_model_t_1eef71_idx'), models.Index(fields=['provider', 'is_active'], name='igny8_ai_mo_provide_fbda6c_idx'), models.Index(fields=['is_default', 'model_type'], name='igny8_ai_mo_is_defa_95bfb9_idx')],
},
),
# Seed initial model data
migrations.RunPython(seed_ai_models, reverse_seed),
]

View File

@@ -142,3 +142,59 @@ class UsageLimitsSerializer(serializers.Serializer):
"""Serializer for usage limits response"""
limits: LimitCardSerializer = LimitCardSerializer(many=True)
class AIModelConfigSerializer(serializers.Serializer):
"""
Serializer for AI Model Configuration (Read-Only API)
Provides model information for frontend dropdowns and displays
"""
model_name = serializers.CharField(read_only=True)
display_name = serializers.CharField(read_only=True)
model_type = serializers.CharField(read_only=True)
provider = serializers.CharField(read_only=True)
# Text model fields
input_cost_per_1m = serializers.DecimalField(
max_digits=10,
decimal_places=4,
read_only=True,
allow_null=True
)
output_cost_per_1m = serializers.DecimalField(
max_digits=10,
decimal_places=4,
read_only=True,
allow_null=True
)
context_window = serializers.IntegerField(read_only=True, allow_null=True)
max_output_tokens = serializers.IntegerField(read_only=True, allow_null=True)
# Image model fields
cost_per_image = serializers.DecimalField(
max_digits=10,
decimal_places=4,
read_only=True,
allow_null=True
)
valid_sizes = serializers.ListField(read_only=True, allow_null=True)
# Capabilities
supports_json_mode = serializers.BooleanField(read_only=True)
supports_vision = serializers.BooleanField(read_only=True)
supports_function_calling = serializers.BooleanField(read_only=True)
# Status
is_default = serializers.BooleanField(read_only=True)
sort_order = serializers.IntegerField(read_only=True)
# Computed field
pricing_display = serializers.SerializerMethodField()
def get_pricing_display(self, obj):
"""Generate pricing display string based on model type"""
if obj.model_type == 'text':
return f"${obj.input_cost_per_1m}/{obj.output_cost_per_1m} per 1M"
elif obj.model_type == 'image':
return f"${obj.cost_per_image} per image"
return ""

View File

@@ -751,3 +751,75 @@ class AdminBillingViewSet(viewsets.ViewSet):
return Response({'error': 'Method not found'}, status=404)
@extend_schema_view(
list=extend_schema(tags=['AI Models'], summary='List available AI models'),
retrieve=extend_schema(tags=['AI Models'], summary='Get AI model details'),
)
class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for AI Model Configuration (Read-Only)
Provides model information for frontend dropdowns and displays
"""
permission_classes = [IsAuthenticatedAndActive]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
throttle_scope = 'billing'
throttle_classes = [DebugScopedRateThrottle]
pagination_class = None # No pagination for model lists
lookup_field = 'model_name'
def get_queryset(self):
"""Get AIModelConfig queryset with filters"""
from igny8_core.business.billing.models import AIModelConfig
queryset = AIModelConfig.objects.filter(is_active=True)
# Filter by model type
model_type = self.request.query_params.get('type', None)
if model_type:
queryset = queryset.filter(model_type=model_type)
# Filter by provider
provider = self.request.query_params.get('provider', None)
if provider:
queryset = queryset.filter(provider=provider)
# Filter by default
is_default = self.request.query_params.get('default', None)
if is_default is not None:
is_default_bool = is_default.lower() in ['true', '1', 'yes']
queryset = queryset.filter(is_default=is_default_bool)
return queryset.order_by('model_type', 'sort_order', 'model_name')
def get_serializer_class(self):
"""Return serializer class"""
from .serializers import AIModelConfigSerializer
return AIModelConfigSerializer
def list(self, request, *args, **kwargs):
"""List all available models with filters"""
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return success_response(
data=serializer.data,
message='AI models retrieved successfully'
)
def retrieve(self, request, *args, **kwargs):
"""Get details for a specific model"""
try:
instance = self.get_queryset().get(model_name=kwargs.get('model_name'))
serializer = self.get_serializer(instance)
return success_response(
data=serializer.data,
message='AI model details retrieved successfully'
)
except Exception as e:
return error_response(
message='Model not found',
errors={'model_name': [str(e)]},
status_code=status.HTTP_404_NOT_FOUND
)