New Model & tokens/credits updates

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-23 06:26:15 +00:00
parent 1d4825ad77
commit d768ed71d4
9 changed files with 945 additions and 35 deletions

View File

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

View File

@@ -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
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

119
backend/seed_ai_models.py Normal file
View File

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