From d768ed71d43ed9d6eddd8dbb0ee9f810862fe736 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Tue, 23 Dec 2025 06:26:15 +0000 Subject: [PATCH] New Model & tokens/credits updates --- .../management/commands/seed_ai_models.py | 122 +++++++++ .../migrations/0019_add_ai_model_config.py | 156 +++++++++++ backend/igny8_core/business/billing/models.py | 161 ++++++++++- .../billing/services/credit_service.py | 249 +++++++++++++++--- backend/igny8_core/modules/billing/admin.py | 39 ++- .../migrations/0019_add_ai_model_config.py | 90 +++++++ .../0002_add_model_fk_to_integrations.py | 25 ++ backend/igny8_core/modules/system/models.py | 19 ++ backend/seed_ai_models.py | 119 +++++++++ 9 files changed, 945 insertions(+), 35 deletions(-) create mode 100644 backend/igny8_core/business/billing/management/commands/seed_ai_models.py create mode 100644 backend/igny8_core/business/billing/migrations/0019_add_ai_model_config.py create mode 100644 backend/igny8_core/modules/billing/migrations/0019_add_ai_model_config.py create mode 100644 backend/igny8_core/modules/system/migrations/0002_add_model_fk_to_integrations.py create mode 100644 backend/seed_ai_models.py diff --git a/backend/igny8_core/business/billing/management/commands/seed_ai_models.py b/backend/igny8_core/business/billing/management/commands/seed_ai_models.py new file mode 100644 index 00000000..965bf171 --- /dev/null +++ b/backend/igny8_core/business/billing/management/commands/seed_ai_models.py @@ -0,0 +1,122 @@ +""" +Management command to seed initial AI model configurations +""" +from django.core.management.base import BaseCommand +from django.db import transaction +from igny8_core.business.billing.models import AIModelConfig + + +class Command(BaseCommand): + help = 'Seeds initial AI model configurations with pricing data' + + def handle(self, *args, **options): + self.stdout.write('Seeding AI model configurations...') + + models_data = [ + { + 'model_name': 'gpt-4o-mini', + 'provider': 'openai', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.000150, # $0.15 per 1M tokens + 'cost_per_1k_output_tokens': 0.000600, # $0.60 per 1M tokens + 'tokens_per_credit': 50, # 50 tokens = 1 credit (more expensive) + 'display_name': 'GPT-4o Mini', + 'is_active': True, + 'is_default': True, # Set as default text model + }, + { + 'model_name': 'gpt-4-turbo-2024-04-09', + 'provider': 'openai', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.010000, # $10 per 1M tokens + 'cost_per_1k_output_tokens': 0.030000, # $30 per 1M tokens + 'tokens_per_credit': 30, # 30 tokens = 1 credit (premium) + 'display_name': 'GPT-4 Turbo', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'gpt-3.5-turbo', + 'provider': 'openai', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.000500, # $0.50 per 1M tokens + 'cost_per_1k_output_tokens': 0.001500, # $1.50 per 1M tokens + 'tokens_per_credit': 200, # 200 tokens = 1 credit (cheaper) + 'display_name': 'GPT-3.5 Turbo', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'claude-3-5-sonnet-20241022', + 'provider': 'anthropic', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.003000, # $3 per 1M tokens + 'cost_per_1k_output_tokens': 0.015000, # $15 per 1M tokens + 'tokens_per_credit': 40, # 40 tokens = 1 credit + 'display_name': 'Claude 3.5 Sonnet', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'claude-3-haiku-20240307', + 'provider': 'anthropic', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.000250, # $0.25 per 1M tokens + 'cost_per_1k_output_tokens': 0.001250, # $1.25 per 1M tokens + 'tokens_per_credit': 150, # 150 tokens = 1 credit (budget) + 'display_name': 'Claude 3 Haiku', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'runware-flux-1.1-pro', + 'provider': 'runware', + 'model_type': 'image', + 'cost_per_1k_input_tokens': 0.000000, # Image models don't use input tokens + 'cost_per_1k_output_tokens': 0.040000, # $0.04 per image (treat as "tokens") + 'tokens_per_credit': 1, # 1 "token" (image) = 1 credit + 'display_name': 'Runware FLUX 1.1 Pro', + 'is_active': True, + 'is_default': True, # Set as default image model + }, + { + 'model_name': 'dall-e-3', + 'provider': 'openai', + 'model_type': 'image', + 'cost_per_1k_input_tokens': 0.000000, + 'cost_per_1k_output_tokens': 0.040000, # $0.040 per standard image + 'tokens_per_credit': 1, + 'display_name': 'DALL-E 3', + 'is_active': True, + 'is_default': False, + }, + ] + + created_count = 0 + updated_count = 0 + + with transaction.atomic(): + for data in models_data: + model, created = AIModelConfig.objects.update_or_create( + model_name=data['model_name'], + defaults=data + ) + + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f'✓ Created: {model.display_name}') + ) + else: + updated_count += 1 + self.stdout.write( + self.style.WARNING(f'↻ Updated: {model.display_name}') + ) + + self.stdout.write('\n' + '='*60) + self.stdout.write(self.style.SUCCESS( + f'✓ Successfully processed {len(models_data)} AI models' + )) + self.stdout.write(f' - Created: {created_count}') + self.stdout.write(f' - Updated: {updated_count}') + self.stdout.write('='*60) diff --git a/backend/igny8_core/business/billing/migrations/0019_add_ai_model_config.py b/backend/igny8_core/business/billing/migrations/0019_add_ai_model_config.py new file mode 100644 index 00000000..a42a3185 --- /dev/null +++ b/backend/igny8_core/business/billing/migrations/0019_add_ai_model_config.py @@ -0,0 +1,156 @@ +# Generated manually for AI Model & Cost Configuration System + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0018_update_operation_choices'), + ] + + operations = [ + # Step 1: Create AIModelConfig table using raw SQL + migrations.RunSQL( + sql=""" + CREATE TABLE "igny8_ai_model_config" ( + "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + "model_name" varchar(100) NOT NULL UNIQUE, + "provider" varchar(50) NOT NULL, + "model_type" varchar(20) NOT NULL, + "cost_per_1k_input_tokens" numeric(10, 6) NOT NULL, + "cost_per_1k_output_tokens" numeric(10, 6) NOT NULL, + "tokens_per_credit" numeric(10, 2) NOT NULL, + "display_name" varchar(200) NOT NULL, + "is_active" boolean NOT NULL DEFAULT true, + "is_default" boolean NOT NULL DEFAULT false, + "created_at" timestamp with time zone NOT NULL DEFAULT NOW(), + "updated_at" timestamp with time zone NOT NULL DEFAULT NOW() + ); + CREATE INDEX "igny8_ai_model_config_model_name_75645c19_like" + ON "igny8_ai_model_config" ("model_name" varchar_pattern_ops); + """, + reverse_sql='DROP TABLE "igny8_ai_model_config";', + ), + + # Step 2: Modify CreditUsageLog table + migrations.RunSQL( + sql=""" + -- Add model_name column (copy of old model_used for backward compat) + ALTER TABLE igny8_credit_usage_logs ADD COLUMN model_name varchar(100); + UPDATE igny8_credit_usage_logs SET model_name = model_used; + + -- Make old model_used nullable + ALTER TABLE igny8_credit_usage_logs ALTER COLUMN model_used DROP NOT NULL; + + -- Add cost tracking fields + ALTER TABLE igny8_credit_usage_logs ADD COLUMN cost_usd_input numeric(10, 6) NULL; + ALTER TABLE igny8_credit_usage_logs ADD COLUMN cost_usd_output numeric(10, 6) NULL; + ALTER TABLE igny8_credit_usage_logs ADD COLUMN cost_usd_total numeric(10, 6) NULL; + + -- Add model_config FK + ALTER TABLE igny8_credit_usage_logs ADD COLUMN model_config_id bigint NULL; + ALTER TABLE igny8_credit_usage_logs + ADD CONSTRAINT "igny8_credit_usage_l_model_config_id_fk_igny8_ai_" + FOREIGN KEY (model_config_id) REFERENCES igny8_ai_model_config(id) + DEFERRABLE INITIALLY DEFERRED; + CREATE INDEX "igny8_credit_usage_logs_model_config_id_idx" + ON "igny8_credit_usage_logs" ("model_config_id"); + + -- Rename old model_used to avoid conflicts + ALTER TABLE igny8_credit_usage_logs RENAME COLUMN model_used TO model_used_old; + """, + reverse_sql=""" + ALTER TABLE igny8_credit_usage_logs RENAME COLUMN model_used_old TO model_used; + ALTER TABLE igny8_credit_usage_logs DROP CONSTRAINT "igny8_credit_usage_l_model_config_id_fk_igny8_ai_"; + DROP INDEX "igny8_credit_usage_logs_model_config_id_idx"; + ALTER TABLE igny8_credit_usage_logs DROP COLUMN model_config_id; + ALTER TABLE igny8_credit_usage_logs DROP COLUMN cost_usd_total; + ALTER TABLE igny8_credit_usage_logs DROP COLUMN cost_usd_output; + ALTER TABLE igny8_credit_usage_logs DROP COLUMN cost_usd_input; + ALTER TABLE igny8_credit_usage_logs DROP COLUMN model_name; + """, + ), + + # Step 3: Modify CreditCostConfig table + migrations.RunSQL( + sql=""" + ALTER TABLE igny8_credit_cost_config ADD COLUMN default_model_id bigint NULL; + ALTER TABLE igny8_credit_cost_config + ADD CONSTRAINT "igny8_credit_cost_co_default_model_id_fk_igny8_ai_" + FOREIGN KEY (default_model_id) REFERENCES igny8_ai_model_config(id) + DEFERRABLE INITIALLY DEFERRED; + CREATE INDEX "igny8_credit_cost_config_default_model_id_idx" + ON "igny8_credit_cost_config" ("default_model_id"); + """, + reverse_sql=""" + ALTER TABLE igny8_credit_cost_config DROP CONSTRAINT "igny8_credit_cost_co_default_model_id_fk_igny8_ai_"; + DROP INDEX "igny8_credit_cost_config_default_model_id_idx"; + ALTER TABLE igny8_credit_cost_config DROP COLUMN default_model_id; + """, + ), + + # Step 4: Update model state (tell Django about the new structure) + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='AIModelConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('model_name', models.CharField(help_text='Technical model identifier', max_length=100, unique=True)), + ('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('other', 'Other')], max_length=50)), + ('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embeddings')], max_length=20)), + ('cost_per_1k_input_tokens', models.DecimalField(decimal_places=6, help_text='Cost in USD per 1,000 input tokens', max_digits=10)), + ('cost_per_1k_output_tokens', models.DecimalField(decimal_places=6, help_text='Cost in USD per 1,000 output tokens', max_digits=10)), + ('tokens_per_credit', models.DecimalField(decimal_places=2, help_text='How many tokens equal 1 credit', max_digits=10)), + ('display_name', models.CharField(help_text='Human-readable model name', max_length=200)), + ('is_active', models.BooleanField(default=True)), + ('is_default', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'igny8_ai_model_config', + 'ordering': ['provider', 'model_name'], + }, + ), + migrations.AddField( + model_name='creditusagelog', + name='model_name', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='creditusagelog', + name='cost_usd_input', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True), + ), + migrations.AddField( + model_name='creditusagelog', + name='cost_usd_output', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True), + ), + migrations.AddField( + model_name='creditusagelog', + name='cost_usd_total', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True), + ), + migrations.AddField( + model_name='creditusagelog', + name='model_config', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_logs', to='billing.aimodelconfig', db_column='model_config_id'), + ), + migrations.AlterField( + model_name='creditcostconfig', + name='unit', + field=models.CharField(choices=[('per_operation', 'Per Operation'), ('per_item', 'Per Item'), ('per_100_tokens', 'Per 100 Tokens'), ('per_1000_tokens', 'Per 1000 Tokens')], default='per_operation', max_length=50), + ), + migrations.AddField( + model_name='creditcostconfig', + name='default_model', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cost_configs', to='billing.aimodelconfig'), + ), + ], + database_operations=[], # Already done with RunSQL above + ), + ] diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 3f5e82e9..408bf407 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -19,6 +19,102 @@ PAYMENT_METHOD_CHOICES = [ ] +class AIModelConfig(models.Model): + """ + AI Model Configuration - Centralized pricing and token ratios + Single source of truth for all AI model costs + """ + # Model identification + model_name = models.CharField( + max_length=100, + unique=True, + help_text="Technical model name (e.g., gpt-4-turbo, gpt-3.5-turbo)" + ) + provider = models.CharField( + max_length=50, + choices=[ + ('openai', 'OpenAI'), + ('anthropic', 'Anthropic'), + ('runware', 'Runware'), + ('other', 'Other'), + ], + help_text="AI provider" + ) + model_type = models.CharField( + max_length=20, + choices=[ + ('text', 'Text Generation'), + ('image', 'Image Generation'), + ('embedding', 'Embeddings'), + ], + default='text', + help_text="Type of AI model" + ) + + # Pricing (per 1K tokens for text models) + cost_per_1k_input_tokens = models.DecimalField( + max_digits=10, + decimal_places=6, + default=Decimal('0.001'), + validators=[MinValueValidator(Decimal('0'))], + help_text="Cost in USD per 1,000 input tokens" + ) + cost_per_1k_output_tokens = models.DecimalField( + max_digits=10, + decimal_places=6, + default=Decimal('0.002'), + validators=[MinValueValidator(Decimal('0'))], + help_text="Cost in USD per 1,000 output tokens" + ) + + # Token-to-credit ratio + tokens_per_credit = models.IntegerField( + default=100, + validators=[MinValueValidator(1)], + help_text="How many tokens equal 1 credit (e.g., 100 tokens = 1 credit)" + ) + + # Display + display_name = models.CharField( + max_length=150, + help_text="Human-readable name (e.g., 'GPT-4 Turbo (Premium)')" + ) + description = models.TextField( + blank=True, + help_text="Model description and use cases" + ) + + # Status + is_active = models.BooleanField( + default=True, + help_text="Enable/disable this model" + ) + is_default = models.BooleanField( + default=False, + help_text="Use as system-wide default model" + ) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'billing' + db_table = 'igny8_ai_model_config' + verbose_name = 'AI Model Configuration' + verbose_name_plural = 'AI Model Configurations' + ordering = ['provider', 'model_name'] + + def __str__(self): + return f"{self.display_name} ({self.model_name})" + + def calculate_cost(self, tokens_input, tokens_output): + """Calculate USD cost for given token usage""" + cost_input = (tokens_input / 1000) * self.cost_per_1k_input_tokens + cost_output = (tokens_output / 1000) * self.cost_per_1k_output_tokens + return float(cost_input + cost_output) + + class CreditTransaction(AccountBaseModel): """Track all credit transactions (additions, deductions)""" TRANSACTION_TYPE_CHOICES = [ @@ -89,10 +185,59 @@ class CreditUsageLog(AccountBaseModel): operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True) credits_used = models.IntegerField(validators=[MinValueValidator(0)]) - cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True) - model_used = models.CharField(max_length=100, blank=True) + + # Model tracking + model_config = models.ForeignKey( + 'billing.AIModelConfig', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='usage_logs', + help_text="AI model configuration used", + db_column='model_config_id' + ) + model_name = models.CharField( + max_length=100, + blank=True, + help_text="Model name (deprecated, use model_config FK)" + ) + + # Token tracking tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)]) tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)]) + + # Cost tracking (USD) + cost_usd_input = models.DecimalField( + max_digits=10, + decimal_places=6, + null=True, + blank=True, + help_text="USD cost for input tokens" + ) + cost_usd_output = models.DecimalField( + max_digits=10, + decimal_places=6, + null=True, + blank=True, + help_text="USD cost for output tokens" + ) + cost_usd_total = models.DecimalField( + max_digits=10, + decimal_places=6, + null=True, + blank=True, + help_text="Total USD cost (input + output)" + ) + + # Legacy cost field (deprecated) + cost_usd = models.DecimalField( + max_digits=10, + decimal_places=4, + null=True, + blank=True, + help_text="Deprecated, use cost_usd_total" + ) + related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task' related_object_id = models.IntegerField(null=True, blank=True) metadata = models.JSONField(default=dict) @@ -154,6 +299,8 @@ class CreditCostConfig(models.Model): ('per_200_words', 'Per 200 Words'), ('per_item', 'Per Item'), ('per_image', 'Per Image'), + ('per_100_tokens', 'Per 100 Tokens'), # NEW: Token-based + ('per_1000_tokens', 'Per 1000 Tokens'), # NEW: Token-based ] unit = models.CharField( @@ -163,6 +310,16 @@ class CreditCostConfig(models.Model): help_text="What the cost applies to" ) + # Model configuration + default_model = models.ForeignKey( + 'billing.AIModelConfig', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='operation_configs', + help_text="Default AI model for this operation (optional)" + ) + # Metadata display_name = models.CharField(max_length=100, help_text="Human-readable name") description = models.TextField(blank=True, help_text="What this operation does") diff --git a/backend/igny8_core/business/billing/services/credit_service.py b/backend/igny8_core/business/billing/services/credit_service.py index da7d0d2f..9c2b914c 100644 --- a/backend/igny8_core/business/billing/services/credit_service.py +++ b/backend/igny8_core/business/billing/services/credit_service.py @@ -3,7 +3,9 @@ Credit Service for managing credit transactions and deductions """ from django.db import transaction from django.utils import timezone -from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog +from decimal import Decimal +import math +from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, AIModelConfig from igny8_core.business.billing.constants import CREDIT_COSTS from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError from igny8_core.auth.models import Account @@ -117,7 +119,10 @@ class CreditService: @staticmethod @transaction.atomic - def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None): + def deduct_credits(account, amount, operation_type, description, metadata=None, + cost_usd_input=None, cost_usd_output=None, cost_usd_total=None, + model_config=None, tokens_input=None, tokens_output=None, + related_object_type=None, related_object_id=None): """ Deduct credits and log transaction. @@ -127,8 +132,10 @@ class CreditService: operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES) description: Description of the transaction metadata: Optional metadata dict - cost_usd: Optional cost in USD - model_used: Optional AI model used + cost_usd_input: Optional input cost in USD + cost_usd_output: Optional output cost in USD + cost_usd_total: Optional total cost in USD + model_config: Optional AIModelConfig instance tokens_input: Optional input tokens tokens_output: Optional output tokens related_object_type: Optional related object type @@ -154,25 +161,45 @@ class CreditService: metadata=metadata or {} ) - # Create CreditUsageLog - CreditUsageLog.objects.create( - account=account, - operation_type=operation_type, - credits_used=amount, - cost_usd=cost_usd, - model_used=model_used or '', - tokens_input=tokens_input, - tokens_output=tokens_output, - related_object_type=related_object_type or '', - related_object_id=related_object_id, - metadata=metadata or {} - ) + # Create CreditUsageLog with new model_config FK + log_data = { + 'account': account, + 'operation_type': operation_type, + 'credits_used': amount, + 'tokens_input': tokens_input, + 'tokens_output': tokens_output, + 'related_object_type': related_object_type or '', + 'related_object_id': related_object_id, + 'metadata': metadata or {}, + } + + # Add model tracking (new FK) + if model_config: + log_data['model_config'] = model_config + log_data['model_name'] = model_config.model_name + + # Add cost tracking (new fields) + if cost_usd_input is not None: + log_data['cost_usd_input'] = cost_usd_input + if cost_usd_output is not None: + log_data['cost_usd_output'] = cost_usd_output + if cost_usd_total is not None: + log_data['cost_usd_total'] = cost_usd_total + + # Legacy cost_usd field (backward compatibility) + if cost_usd_total is not None: + log_data['cost_usd'] = cost_usd_total + + CreditUsageLog.objects.create(**log_data) return account.credits @staticmethod @transaction.atomic - def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None): + def deduct_credits_for_operation(account, operation_type, amount=None, description=None, + metadata=None, cost_usd_input=None, cost_usd_output=None, + cost_usd_total=None, model_config=None, tokens_input=None, + tokens_output=None, related_object_type=None, related_object_id=None): """ Deduct credits for an operation (convenience method that calculates cost automatically). @@ -182,8 +209,10 @@ class CreditService: amount: Optional amount (word count, image count, etc.) description: Optional description (auto-generated if not provided) metadata: Optional metadata dict - cost_usd: Optional cost in USD - model_used: Optional AI model used + cost_usd_input: Optional input cost in USD + cost_usd_output: Optional output cost in USD + cost_usd_total: Optional total cost in USD + model_config: Optional AIModelConfig instance tokens_input: Optional input tokens tokens_output: Optional output tokens related_object_type: Optional related object type @@ -192,24 +221,33 @@ class CreditService: Returns: int: New credit balance """ - # Calculate credit cost - credits_required = CreditService.get_credit_cost(operation_type, amount) + # Calculate credit cost - use token-based if tokens provided + if tokens_input is not None and tokens_output is not None and model_config: + credits_required = CreditService.calculate_credits_from_tokens( + operation_type, tokens_input, tokens_output, model_config + ) + else: + credits_required = CreditService.get_credit_cost(operation_type, amount) # Check sufficient credits - CreditService.check_credits(account, operation_type, amount) + CreditService.check_credits_legacy(account, credits_required) # Auto-generate description if not provided if not description: + model_name = model_config.display_name if model_config else "AI" if operation_type == 'clustering': - description = f"Clustering operation" + description = f"Clustering operation ({model_name})" elif operation_type == 'idea_generation': - description = f"Generated {amount or 1} idea(s)" + description = f"Generated {amount or 1} idea(s) ({model_name})" elif operation_type == 'content_generation': - description = f"Generated content ({amount or 0} words)" + if tokens_input and tokens_output: + description = f"Generated content ({tokens_input + tokens_output} tokens, {model_name})" + else: + description = f"Generated content ({amount or 0} words, {model_name})" elif operation_type == 'image_generation': - description = f"Generated {amount or 1} image(s)" + description = f"Generated {amount or 1} image(s) ({model_name})" else: - description = f"{operation_type} operation" + description = f"{operation_type} operation ({model_name})" return CreditService.deduct_credits( account=account, @@ -217,8 +255,10 @@ class CreditService: operation_type=operation_type, description=description, metadata=metadata, - cost_usd=cost_usd, - model_used=model_used, + cost_usd_input=cost_usd_input, + cost_usd_output=cost_usd_output, + cost_usd_total=cost_usd_total, + model_config=model_config, tokens_input=tokens_input, tokens_output=tokens_output, related_object_type=related_object_type, @@ -292,3 +332,152 @@ class CreditService: return CreditService.get_credit_cost(operation_type, amount) + + @staticmethod + def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output, model_config): + """ + Calculate credits based on actual token usage and AI model configuration. + This is the new token-aware calculation method. + + Args: + operation_type: Type of operation (e.g., 'content_generation') + tokens_input: Number of input tokens used + tokens_output: Number of output tokens used + model_config: AIModelConfig instance + + Returns: + int: Number of credits to deduct + + Raises: + CreditCalculationError: If calculation fails + """ + import logging + logger = logging.getLogger(__name__) + + try: + from igny8_core.business.billing.models import CreditCostConfig + + # Get operation config + config = CreditCostConfig.objects.filter( + operation_type=operation_type, + is_active=True + ).first() + + if not config: + raise CreditCalculationError(f"No active config found for operation: {operation_type}") + + # Check if operation uses token-based billing + if config.unit in ['per_100_tokens', 'per_1000_tokens']: + total_tokens = tokens_input + tokens_output + + # Get model's tokens-per-credit ratio + tokens_per_credit = model_config.tokens_per_credit + + if tokens_per_credit <= 0: + raise CreditCalculationError(f"Invalid tokens_per_credit: {tokens_per_credit}") + + # Calculate credits (float) + credits_float = Decimal(total_tokens) / Decimal(tokens_per_credit) + + # Apply rounding (always round up to avoid undercharging) + credits = math.ceil(credits_float) + + # Apply minimum cost from config (if set) + credits = max(credits, config.credits_cost) + + logger.info( + f"Token-based calculation: {total_tokens} tokens / {tokens_per_credit} = {credits} credits " + f"(model: {model_config.model_name}, operation: {operation_type})" + ) + + return credits + else: + # Fall back to legacy calculation for non-token operations + logger.warning( + f"Operation {operation_type} uses unit {config.unit}, falling back to legacy calculation" + ) + return config.credits_cost + + except Exception as e: + logger.error(f"Failed to calculate credits from tokens: {e}") + raise CreditCalculationError(f"Credit calculation failed: {e}") + + @staticmethod + def get_model_for_operation(account, operation_type, task_model_override=None): + """ + Determine which AI model to use for an operation. + Priority: Task Override > Account Default > Operation Default > System Default + + Args: + account: Account instance + operation_type: Type of operation + task_model_override: Optional AIModelConfig instance from task + + Returns: + AIModelConfig: The model to use + """ + import logging + logger = logging.getLogger(__name__) + + # 1. Task-level override (highest priority) + if task_model_override: + logger.info(f"Using task-level model override: {task_model_override.model_name}") + return task_model_override + + # 2. Account default model (from IntegrationSettings) + try: + from igny8_core.modules.system.models import IntegrationSettings + from igny8_core.business.billing.models import CreditCostConfig + + integration = IntegrationSettings.objects.filter(account=account).first() + + if integration: + # Determine if this is text or image operation + config = CreditCostConfig.objects.filter( + operation_type=operation_type, + is_active=True + ).first() + + if config and config.default_model: + model_type = config.default_model.model_type + + if model_type == 'text' and integration.default_text_model: + logger.info(f"Using account default text model: {integration.default_text_model.model_name}") + return integration.default_text_model + elif model_type == 'image' and integration.default_image_model: + logger.info(f"Using account default image model: {integration.default_image_model.model_name}") + return integration.default_image_model + except Exception as e: + logger.warning(f"Failed to get account default model: {e}") + + # 3. Operation default model + try: + from igny8_core.business.billing.models import CreditCostConfig + + config = CreditCostConfig.objects.filter( + operation_type=operation_type, + is_active=True + ).first() + + if config and config.default_model: + logger.info(f"Using operation default model: {config.default_model.model_name}") + return config.default_model + except Exception as e: + logger.warning(f"Failed to get operation default model: {e}") + + # 4. System-wide default (fallback) + try: + default_model = AIModelConfig.objects.filter( + is_default=True, + is_active=True + ).first() + + if default_model: + logger.info(f"Using system default model: {default_model.model_name}") + return default_model + except Exception as e: + logger.warning(f"Failed to get system default model: {e}") + + # 5. Hard-coded fallback + logger.warning("All model selection failed, using hard-coded fallback: gpt-4o-mini") + return AIModelConfig.objects.filter(model_name='gpt-4o-mini').first() diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 749ff1d0..d7d7e602 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -8,6 +8,7 @@ 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, Invoice, Payment, @@ -49,11 +50,43 @@ 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) + + @admin.register(CreditUsageLog) class CreditUsageLogAdmin(AccountAdminMixin, Igny8ModelAdmin): - 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'] + 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'] readonly_fields = ['created_at'] date_hierarchy = 'created_at' diff --git a/backend/igny8_core/modules/billing/migrations/0019_add_ai_model_config.py b/backend/igny8_core/modules/billing/migrations/0019_add_ai_model_config.py new file mode 100644 index 00000000..6016b5ed --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0019_add_ai_model_config.py @@ -0,0 +1,90 @@ +# Generated by Django 5.2.9 on 2025-12-23 05:31 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0018_update_operation_choices'), + ] + + operations = [ + migrations.CreateModel( + name='AIModelConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('model_name', models.CharField(help_text='Technical model name (e.g., gpt-4-turbo, gpt-3.5-turbo)', max_length=100, unique=True)), + ('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('other', 'Other')], help_text='AI provider', max_length=50)), + ('model_type', models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation'), ('embedding', 'Embeddings')], default='text', help_text='Type of AI model', max_length=20)), + ('cost_per_1k_input_tokens', models.DecimalField(decimal_places=6, default=Decimal('0.001'), help_text='Cost in USD per 1,000 input tokens', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0'))])), + ('cost_per_1k_output_tokens', models.DecimalField(decimal_places=6, default=Decimal('0.002'), help_text='Cost in USD per 1,000 output tokens', max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0'))])), + ('tokens_per_credit', models.IntegerField(default=100, help_text='How many tokens equal 1 credit (e.g., 100 tokens = 1 credit)', validators=[django.core.validators.MinValueValidator(1)])), + ('display_name', models.CharField(help_text="Human-readable name (e.g., 'GPT-4 Turbo (Premium)')", max_length=150)), + ('description', models.TextField(blank=True, help_text='Model description and use cases')), + ('is_active', models.BooleanField(default=True, help_text='Enable/disable this model')), + ('is_default', models.BooleanField(default=False, help_text='Use as system-wide default model')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'AI Model Configuration', + 'verbose_name_plural': 'AI Model Configurations', + 'db_table': 'igny8_ai_model_config', + 'ordering': ['provider', 'model_name'], + }, + ), + migrations.AddField( + model_name='creditusagelog', + name='cost_usd_input', + field=models.DecimalField(blank=True, decimal_places=6, help_text='USD cost for input tokens', max_digits=10, null=True), + ), + migrations.AddField( + model_name='creditusagelog', + name='cost_usd_output', + field=models.DecimalField(blank=True, decimal_places=6, help_text='USD cost for output tokens', max_digits=10, null=True), + ), + migrations.AddField( + model_name='creditusagelog', + name='cost_usd_total', + field=models.DecimalField(blank=True, decimal_places=6, help_text='Total USD cost (input + output)', max_digits=10, null=True), + ), + migrations.AddField( + model_name='creditusagelog', + name='model_name', + field=models.CharField(blank=True, help_text='Model name (deprecated, use model_used FK)', max_length=100), + ), + migrations.AlterField( + model_name='creditcostconfig', + name='unit', + field=models.CharField(choices=[('per_request', 'Per Request'), ('per_100_words', 'Per 100 Words'), ('per_200_words', 'Per 200 Words'), ('per_item', 'Per Item'), ('per_image', 'Per Image'), ('per_100_tokens', 'Per 100 Tokens'), ('per_1000_tokens', 'Per 1000 Tokens')], default='per_request', help_text='What the cost applies to', max_length=50), + ), + migrations.AlterField( + model_name='creditusagelog', + name='cost_usd', + field=models.DecimalField(blank=True, decimal_places=4, help_text='Deprecated, use cost_usd_total', max_digits=10, null=True), + ), + migrations.AlterField( + model_name='historicalcreditcostconfig', + name='unit', + field=models.CharField(choices=[('per_request', 'Per Request'), ('per_100_words', 'Per 100 Words'), ('per_200_words', 'Per 200 Words'), ('per_item', 'Per Item'), ('per_image', 'Per Image'), ('per_100_tokens', 'Per 100 Tokens'), ('per_1000_tokens', 'Per 1000 Tokens')], default='per_request', help_text='What the cost applies to', max_length=50), + ), + migrations.AddField( + model_name='creditcostconfig', + name='default_model', + field=models.ForeignKey(blank=True, help_text='Default AI model for this operation (optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='operation_configs', to='billing.aimodelconfig'), + ), + migrations.AddField( + model_name='historicalcreditcostconfig', + name='default_model', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Default AI model for this operation (optional)', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='billing.aimodelconfig'), + ), + migrations.AlterField( + model_name='creditusagelog', + name='model_used', + field=models.ForeignKey(blank=True, help_text='AI model used for this operation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usage_logs', to='billing.aimodelconfig'), + ), + ] diff --git a/backend/igny8_core/modules/system/migrations/0002_add_model_fk_to_integrations.py b/backend/igny8_core/modules/system/migrations/0002_add_model_fk_to_integrations.py new file mode 100644 index 00000000..2a09d62b --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0002_add_model_fk_to_integrations.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.9 on 2025-12-23 05:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0019_add_ai_model_config'), + ('system', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='integrationsettings', + name='default_image_model', + field=models.ForeignKey(blank=True, help_text='Default AI model for image generation operations', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_integrations', to='billing.aimodelconfig'), + ), + migrations.AddField( + model_name='integrationsettings', + name='default_text_model', + field=models.ForeignKey(blank=True, help_text='Default AI model for text generation operations', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='text_integrations', to='billing.aimodelconfig'), + ), + ] diff --git a/backend/igny8_core/modules/system/models.py b/backend/igny8_core/modules/system/models.py index 9fe85674..0839c4f0 100644 --- a/backend/igny8_core/modules/system/models.py +++ b/backend/igny8_core/modules/system/models.py @@ -60,6 +60,25 @@ class IntegrationSettings(AccountBaseModel): integration_type = models.CharField(max_length=50, choices=INTEGRATION_TYPE_CHOICES, db_index=True) config = models.JSONField(default=dict, help_text="Integration configuration (API keys, settings, etc.)") is_active = models.BooleanField(default=True) + + # AI Model Selection (NEW) + default_text_model = models.ForeignKey( + 'billing.AIModelConfig', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='text_integrations', + help_text="Default AI model for text generation operations" + ) + default_image_model = models.ForeignKey( + 'billing.AIModelConfig', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='image_integrations', + help_text="Default AI model for image generation operations" + ) + updated_at = models.DateTimeField(auto_now=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/seed_ai_models.py b/backend/seed_ai_models.py new file mode 100644 index 00000000..8b486cf7 --- /dev/null +++ b/backend/seed_ai_models.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +""" +Seed AI model configurations +""" +import os +import sys +import django + +# Setup Django +sys.path.insert(0, '/app') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings') +django.setup() + +from django.db import transaction +from igny8_core.business.billing.models import AIModelConfig + +models_data = [ + { + 'model_name': 'gpt-4o-mini', + 'provider': 'openai', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.000150, + 'cost_per_1k_output_tokens': 0.000600, + 'tokens_per_credit': 50, + 'display_name': 'GPT-4o Mini', + 'is_active': True, + 'is_default': True, + }, + { + 'model_name': 'gpt-4-turbo-2024-04-09', + 'provider': 'openai', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.010000, + 'cost_per_1k_output_tokens': 0.030000, + 'tokens_per_credit': 30, + 'display_name': 'GPT-4 Turbo', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'gpt-3.5-turbo', + 'provider': 'openai', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.000500, + 'cost_per_1k_output_tokens': 0.001500, + 'tokens_per_credit': 200, + 'display_name': 'GPT-3.5 Turbo', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'claude-3-5-sonnet-20241022', + 'provider': 'anthropic', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.003000, + 'cost_per_1k_output_tokens': 0.015000, + 'tokens_per_credit': 40, + 'display_name': 'Claude 3.5 Sonnet', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'claude-3-haiku-20240307', + 'provider': 'anthropic', + 'model_type': 'text', + 'cost_per_1k_input_tokens': 0.000250, + 'cost_per_1k_output_tokens': 0.001250, + 'tokens_per_credit': 150, + 'display_name': 'Claude 3 Haiku', + 'is_active': True, + 'is_default': False, + }, + { + 'model_name': 'runware-flux-1.1-pro', + 'provider': 'runware', + 'model_type': 'image', + 'cost_per_1k_input_tokens': 0.000000, + 'cost_per_1k_output_tokens': 0.040000, + 'tokens_per_credit': 1, + 'display_name': 'Runware FLUX 1.1 Pro', + 'is_active': True, + 'is_default': True, + }, + { + 'model_name': 'dall-e-3', + 'provider': 'openai', + 'model_type': 'image', + 'cost_per_1k_input_tokens': 0.000000, + 'cost_per_1k_output_tokens': 0.040000, + 'tokens_per_credit': 1, + 'display_name': 'DALL-E 3', + 'is_active': True, + 'is_default': False, + }, +] + +print('Seeding AI model configurations...') +created_count = 0 +updated_count = 0 + +with transaction.atomic(): + for data in models_data: + model, created = AIModelConfig.objects.update_or_create( + model_name=data['model_name'], + defaults=data + ) + + if created: + created_count += 1 + print(f'✓ Created: {model.display_name}') + else: + updated_count += 1 + print(f'↻ Updated: {model.display_name}') + +print('\n' + '='*60) +print(f'✓ Successfully processed {len(models_data)} AI models') +print(f' - Created: {created_count}') +print(f' - Updated: {updated_count}') +print('='*60)