django admin Groups reorg, Frontend udpates for site settings, #Migration runs

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-05 01:21:52 +00:00
parent 6e30d2d4e8
commit dc7a459ebb
39 changed files with 3142 additions and 1589 deletions

View File

@@ -13,8 +13,6 @@ from django.conf import settings
from .constants import (
DEFAULT_AI_MODEL,
JSON_MODE_MODELS,
MODEL_RATES,
IMAGE_MODEL_RATES,
VALID_OPENAI_IMAGE_MODELS,
VALID_SIZES_BY_MODEL,
DEBUG_MODE,
@@ -45,21 +43,18 @@ class AICore:
self._load_account_settings()
def _load_account_settings(self):
"""Load API keys from GlobalIntegrationSettings (platform-wide, used by ALL accounts)"""
"""Load API keys from IntegrationProvider (centralized provider config)"""
try:
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
from igny8_core.ai.model_registry import ModelRegistry
# 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
self._bria_api_key = getattr(global_settings, 'bria_api_key', None)
self._anthropic_api_key = getattr(global_settings, 'anthropic_api_key', None)
# Load API keys from IntegrationProvider (centralized, platform-wide)
self._openai_api_key = ModelRegistry.get_api_key('openai')
self._runware_api_key = ModelRegistry.get_api_key('runware')
self._bria_api_key = ModelRegistry.get_api_key('bria')
self._anthropic_api_key = ModelRegistry.get_api_key('anthropic')
except Exception as e:
logger.error(f"Could not load GlobalIntegrationSettings: {e}", exc_info=True)
logger.error(f"Could not load API keys from IntegrationProvider: {e}", exc_info=True)
self._openai_api_key = None
self._runware_api_key = None
self._bria_api_key = None
@@ -169,12 +164,12 @@ class AICore:
logger.info(f" - Model used in request: {active_model}")
tracker.ai_call(f"Using model: {active_model}")
# Use ModelRegistry for validation with fallback to constants
# Use ModelRegistry for validation (database-driven)
from igny8_core.ai.model_registry import ModelRegistry
if not ModelRegistry.validate_model(active_model):
# Fallback check against constants for backward compatibility
if active_model not in MODEL_RATES:
error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}"
# Get list of supported models from database
supported_models = [m.model_name for m in ModelRegistry.list_models(model_type='text')]
error_msg = f"Model '{active_model}' is not supported. Supported models: {supported_models}"
logger.error(f"[AICore] {error_msg}")
tracker.error('ConfigurationError', error_msg)
return {
@@ -305,17 +300,13 @@ class AICore:
tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})")
tracker.parse(f"Content length: {len(content)} characters")
# Step 10: Calculate cost using ModelRegistry (with fallback to constants)
# Step 10: Calculate cost using ModelRegistry (database-driven)
from igny8_core.ai.model_registry import ModelRegistry
cost = float(ModelRegistry.calculate_cost(
active_model,
input_tokens=input_tokens,
output_tokens=output_tokens
))
# Fallback to constants if ModelRegistry returns 0
if cost == 0:
rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00})
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
tracker.parse(f"Cost calculated: ${cost:.6f}")
tracker.done("Request completed successfully")
@@ -902,11 +893,9 @@ class AICore:
image_url = image_data.get('url')
revised_prompt = image_data.get('revised_prompt')
# Use ModelRegistry for image cost (with fallback to constants)
# Use ModelRegistry for image cost (database-driven)
from igny8_core.ai.model_registry import ModelRegistry
cost = float(ModelRegistry.calculate_cost(model, num_images=n))
if cost == 0:
cost = IMAGE_MODEL_RATES.get(model, 0.040) * n
print(f"[AI][{function_name}] Step 5: Image generated successfully")
print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}")
print(f"[AI][{function_name}][Success] Image generation completed")
@@ -1361,24 +1350,13 @@ class AICore:
}
def calculate_cost(self, model: str, input_tokens: int, output_tokens: int, model_type: str = 'text') -> float:
"""Calculate cost for API call using ModelRegistry with fallback to constants"""
"""Calculate cost for API call using ModelRegistry (database-driven)"""
from igny8_core.ai.model_registry import ModelRegistry
if model_type == 'text':
cost = float(ModelRegistry.calculate_cost(model, input_tokens=input_tokens, output_tokens=output_tokens))
if cost == 0:
# Fallback to constants
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
input_cost = (input_tokens / 1_000_000) * rates['input']
output_cost = (output_tokens / 1_000_000) * rates['output']
return input_cost + output_cost
return cost
return float(ModelRegistry.calculate_cost(model, input_tokens=input_tokens, output_tokens=output_tokens))
elif model_type == 'image':
cost = float(ModelRegistry.calculate_cost(model, num_images=1))
if cost == 0:
rate = IMAGE_MODEL_RATES.get(model, 0.040)
return rate * 1
return cost
return float(ModelRegistry.calculate_cost(model, num_images=1))
return 0.0
# Legacy method names for backward compatibility

View File

@@ -1,7 +1,17 @@
"""
AI Constants - Model pricing, valid models, and configuration constants
AI Constants - Configuration constants for AI operations
NOTE: Model pricing (MODEL_RATES, IMAGE_MODEL_RATES) has been moved to the database
via AIModelConfig. Use ModelRegistry to get model pricing:
from igny8_core.ai.model_registry import ModelRegistry
cost = ModelRegistry.calculate_cost(model_id, input_tokens=N, output_tokens=N)
The constants below are DEPRECATED and kept only for reference/backward compatibility.
Do NOT use MODEL_RATES or IMAGE_MODEL_RATES in new code.
"""
# Model pricing (per 1M tokens) - EXACT from reference plugin model-rates-config.php
# DEPRECATED - Use AIModelConfig database table instead
# Model pricing (per 1M tokens) - kept for reference only
MODEL_RATES = {
'gpt-4.1': {'input': 2.00, 'output': 8.00},
'gpt-4o-mini': {'input': 0.15, 'output': 0.60},
@@ -10,7 +20,8 @@ MODEL_RATES = {
'gpt-5.2': {'input': 1.75, 'output': 14.00},
}
# Image model pricing (per image) - EXACT from reference plugin
# DEPRECATED - Use AIModelConfig database table instead
# Image model pricing (per image) - kept for reference only
IMAGE_MODEL_RATES = {
'dall-e-3': 0.040,
'dall-e-2': 0.020,

View File

@@ -219,32 +219,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
# Helper methods
def _get_max_in_article_images(self, account) -> int:
"""
Get max_in_article_images from settings.
Uses account's IntegrationSettings override, or GlobalIntegrationSettings.
Get max_in_article_images from AISettings (with account override).
"""
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
from igny8_core.modules.system.ai_settings import AISettings
# 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")
# 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})")
max_images = AISettings.get_effective_max_images(account)
logger.info(f"Using max_in_article_images={max_images} for account {account.id}")
return max_images
def _extract_content_elements(self, content: Content, max_images: int) -> Dict:

View File

@@ -67,40 +67,33 @@ class GenerateImagesFunction(BaseAIFunction):
if not tasks:
raise ValueError("No tasks found")
# Get image generation settings
# Try account-specific override, otherwise use GlobalIntegrationSettings
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
# Get image generation settings from AISettings (with account overrides)
from igny8_core.modules.system.ai_settings import AISettings
from igny8_core.ai.model_registry import ModelRegistry
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")
# Get effective settings (AISettings + AccountSettings overrides)
image_style = AISettings.get_effective_image_style(account)
max_images = AISettings.get_effective_max_images(account)
# 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') or global_settings.runware_model
# Get default image model and provider from database
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
provider = model_config.provider if model_config else 'openai'
model = default_model
else:
model = image_settings.get('model') or global_settings.dalle_model
provider = 'openai'
model = 'dall-e-3'
logger.info(f"Using image settings: provider={provider}, model={model}, style={image_style}, max={max_images}")
return {
'tasks': tasks,
'account': account,
'provider': provider,
'model': model,
'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),
'image_type': image_style,
'max_in_article_images': max_images,
}
def build_prompt(self, data: Dict, account=None) -> Dict:

View File

@@ -1,11 +1,10 @@
"""
Model Registry Service
Central registry for AI model configurations with caching.
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
This service provides:
- Database-driven model configuration (from AIModelConfig)
- Fallback to constants.py for backward compatibility
- Integration provider API key retrieval (from IntegrationProvider)
- Caching for performance
- Cost calculation methods
@@ -20,6 +19,9 @@ Usage:
# Calculate cost
cost = ModelRegistry.calculate_cost('gpt-4o-mini', input_tokens=1000, output_tokens=500)
# Get API key for a provider
api_key = ModelRegistry.get_api_key('openai')
"""
import logging
from decimal import Decimal
@@ -33,12 +35,14 @@ MODEL_CACHE_TTL = 300
# Cache key prefix
CACHE_KEY_PREFIX = 'ai_model_'
PROVIDER_CACHE_PREFIX = 'provider_'
class ModelRegistry:
"""
Central registry for AI model configurations with caching.
Uses AIModelConfig from database with fallback to constants.py
Uses AIModelConfig from database for model configs.
Uses IntegrationProvider for API keys.
"""
@classmethod
@@ -46,6 +50,11 @@ class ModelRegistry:
"""Generate cache key for model"""
return f"{CACHE_KEY_PREFIX}{model_id}"
@classmethod
def _get_provider_cache_key(cls, provider_id: str) -> str:
"""Generate cache key for provider"""
return f"{PROVIDER_CACHE_PREFIX}{provider_id}"
@classmethod
def _get_from_db(cls, model_id: str) -> Optional[Any]:
"""Get model config from database"""
@@ -59,46 +68,6 @@ class ModelRegistry:
logger.debug(f"Could not fetch model {model_id} from DB: {e}")
return None
@classmethod
def _get_from_constants(cls, model_id: str) -> Optional[Dict[str, Any]]:
"""
Get model config from constants.py as fallback.
Returns a dict mimicking AIModelConfig attributes.
"""
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
# Check text models first
if model_id in MODEL_RATES:
rates = MODEL_RATES[model_id]
return {
'model_name': model_id,
'display_name': model_id,
'model_type': 'text',
'provider': 'openai',
'input_cost_per_1m': Decimal(str(rates.get('input', 0))),
'output_cost_per_1m': Decimal(str(rates.get('output', 0))),
'cost_per_image': None,
'is_active': True,
'_from_constants': True
}
# Check image models
if model_id in IMAGE_MODEL_RATES:
cost = IMAGE_MODEL_RATES[model_id]
return {
'model_name': model_id,
'display_name': model_id,
'model_type': 'image',
'provider': 'openai' if 'dall-e' in model_id else 'runware',
'input_cost_per_1m': None,
'output_cost_per_1m': None,
'cost_per_image': Decimal(str(cost)),
'is_active': True,
'_from_constants': True
}
return None
@classmethod
def get_model(cls, model_id: str) -> Optional[Any]:
"""
@@ -107,13 +76,12 @@ class ModelRegistry:
Order of lookup:
1. Cache
2. Database (AIModelConfig)
3. constants.py fallback
Args:
model_id: The model identifier (e.g., 'gpt-4o-mini', 'dall-e-3')
Returns:
AIModelConfig instance or dict with model config, None if not found
AIModelConfig instance, None if not found
"""
cache_key = cls._get_cache_key(model_id)
@@ -129,13 +97,7 @@ class ModelRegistry:
cache.set(cache_key, model_config, MODEL_CACHE_TTL)
return model_config
# Fallback to constants
fallback = cls._get_from_constants(model_id)
if fallback:
cache.set(cache_key, fallback, MODEL_CACHE_TTL)
return fallback
logger.warning(f"Model {model_id} not found in DB or constants")
logger.warning(f"Model {model_id} not found in database")
return None
@classmethod
@@ -154,16 +116,6 @@ class ModelRegistry:
if not model:
return Decimal('0')
# Handle dict (from constants fallback)
if isinstance(model, dict):
if rate_type == 'input':
return model.get('input_cost_per_1m') or Decimal('0')
elif rate_type == 'output':
return model.get('output_cost_per_1m') or Decimal('0')
elif rate_type == 'image':
return model.get('cost_per_image') or Decimal('0')
return Decimal('0')
# Handle AIModelConfig instance
if rate_type == 'input':
return model.input_cost_per_1m or Decimal('0')
@@ -195,8 +147,8 @@ class ModelRegistry:
if not model:
return Decimal('0')
# Determine model type
model_type = model.get('model_type') if isinstance(model, dict) else model.model_type
# Get model type from AIModelConfig
model_type = model.model_type
if model_type == 'text':
input_rate = cls.get_rate(model_id, 'input')
@@ -218,7 +170,7 @@ class ModelRegistry:
@classmethod
def get_default_model(cls, model_type: str = 'text') -> Optional[str]:
"""
Get the default model for a given type.
Get the default model for a given type from database.
Args:
model_type: 'text' or 'image'
@@ -236,32 +188,33 @@ class ModelRegistry:
if default:
return default.model_name
except Exception as e:
logger.debug(f"Could not get default {model_type} model from DB: {e}")
# Fallback to constants
from igny8_core.ai.constants import DEFAULT_AI_MODEL
if model_type == 'text':
return DEFAULT_AI_MODEL
elif model_type == 'image':
return 'dall-e-3'
# If no default is set, return first active model of this type
first_active = AIModelConfig.objects.filter(
model_type=model_type,
is_active=True
).order_by('model_name').first()
if first_active:
return first_active.model_name
except Exception as e:
logger.error(f"Could not get default {model_type} model from DB: {e}")
return None
@classmethod
def list_models(cls, model_type: Optional[str] = None, provider: Optional[str] = None) -> list:
"""
List all available models, optionally filtered by type or provider.
List all available models from database, optionally filtered by type or provider.
Args:
model_type: Filter by 'text', 'image', or 'embedding'
provider: Filter by 'openai', 'anthropic', 'runware', etc.
Returns:
List of model configs
List of AIModelConfig instances
"""
models = []
try:
from igny8_core.business.billing.models import AIModelConfig
queryset = AIModelConfig.objects.filter(is_active=True)
@@ -271,27 +224,10 @@ class ModelRegistry:
if provider:
queryset = queryset.filter(provider=provider)
models = list(queryset.order_by('sort_order', 'model_name'))
return list(queryset.order_by('model_name'))
except Exception as e:
logger.debug(f"Could not list models from DB: {e}")
# Add models from constants if not in DB
if not models:
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
if model_type in (None, 'text'):
for model_id in MODEL_RATES:
fallback = cls._get_from_constants(model_id)
if fallback:
models.append(fallback)
if model_type in (None, 'image'):
for model_id in IMAGE_MODEL_RATES:
fallback = cls._get_from_constants(model_id)
if fallback:
models.append(fallback)
return models
logger.error(f"Could not list models from DB: {e}")
return []
@classmethod
def clear_cache(cls, model_id: Optional[str] = None):
@@ -311,10 +247,10 @@ class ModelRegistry:
if hasattr(default_cache, 'delete_pattern'):
default_cache.delete_pattern(f"{CACHE_KEY_PREFIX}*")
else:
# Fallback: clear known models
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
for model_id in list(MODEL_RATES.keys()) + list(IMAGE_MODEL_RATES.keys()):
cache.delete(cls._get_cache_key(model_id))
# Fallback: clear all known models from DB
from igny8_core.business.billing.models import AIModelConfig
for model in AIModelConfig.objects.values_list('model_name', flat=True):
cache.delete(cls._get_cache_key(model))
except Exception as e:
logger.warning(f"Could not clear all model caches: {e}")
@@ -332,8 +268,110 @@ class ModelRegistry:
model = cls.get_model(model_id)
if not model:
return False
# Check if active
if isinstance(model, dict):
return model.get('is_active', True)
return model.is_active
# ========== IntegrationProvider methods ==========
@classmethod
def get_provider(cls, provider_id: str) -> Optional[Any]:
"""
Get IntegrationProvider by provider_id.
Args:
provider_id: The provider identifier (e.g., 'openai', 'stripe', 'resend')
Returns:
IntegrationProvider instance, None if not found
"""
cache_key = cls._get_provider_cache_key(provider_id)
# Try cache first
cached = cache.get(cache_key)
if cached is not None:
return cached
try:
from igny8_core.modules.system.models import IntegrationProvider
provider = IntegrationProvider.objects.filter(
provider_id=provider_id,
is_active=True
).first()
if provider:
cache.set(cache_key, provider, MODEL_CACHE_TTL)
return provider
except Exception as e:
logger.error(f"Could not fetch provider {provider_id} from DB: {e}")
return None
@classmethod
def get_api_key(cls, provider_id: str) -> Optional[str]:
"""
Get API key for a provider.
Args:
provider_id: The provider identifier (e.g., 'openai', 'anthropic', 'runware')
Returns:
API key string, None if not found or provider is inactive
"""
provider = cls.get_provider(provider_id)
if provider and provider.api_key:
return provider.api_key
return None
@classmethod
def get_api_secret(cls, provider_id: str) -> Optional[str]:
"""
Get API secret for a provider (for OAuth, Stripe secret key, etc.).
Args:
provider_id: The provider identifier
Returns:
API secret string, None if not found
"""
provider = cls.get_provider(provider_id)
if provider and provider.api_secret:
return provider.api_secret
return None
@classmethod
def get_webhook_secret(cls, provider_id: str) -> Optional[str]:
"""
Get webhook secret for a provider (for Stripe, PayPal webhooks).
Args:
provider_id: The provider identifier
Returns:
Webhook secret string, None if not found
"""
provider = cls.get_provider(provider_id)
if provider and provider.webhook_secret:
return provider.webhook_secret
return None
@classmethod
def clear_provider_cache(cls, provider_id: Optional[str] = None):
"""
Clear provider cache.
Args:
provider_id: Clear specific provider cache, or all if None
"""
if provider_id:
cache.delete(cls._get_provider_cache_key(provider_id))
else:
try:
from django.core.cache import caches
default_cache = caches['default']
if hasattr(default_cache, 'delete_pattern'):
default_cache.delete_pattern(f"{PROVIDER_CACHE_PREFIX}*")
else:
from igny8_core.modules.system.models import IntegrationProvider
for pid in IntegrationProvider.objects.values_list('provider_id', flat=True):
cache.delete(cls._get_provider_cache_key(pid))
except Exception as e:
logger.warning(f"Could not clear provider caches: {e}")

View File

@@ -1,6 +1,7 @@
"""
AI Settings - Centralized model configurations and limits
Uses global settings with optional per-account overrides.
Uses AISettings (system defaults) with optional per-account overrides via AccountSettings.
API keys are stored in IntegrationProvider.
"""
from typing import Dict, Any
import logging
@@ -22,10 +23,9 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
Get model configuration for AI function.
Architecture:
- API keys: ALWAYS from GlobalIntegrationSettings (platform-wide)
- Model/params: From IntegrationSettings if account has override, else from global
- Free plan: Cannot override, uses global defaults
- Starter/Growth/Scale: Can override model, temperature, max_tokens, etc.
- API keys: From IntegrationProvider (centralized)
- Model: From AIModelConfig (is_default=True)
- Params: From AISettings with AccountSettings overrides
Args:
function_name: Name of the AI function
@@ -44,25 +44,30 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
try:
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.modules.system.ai_settings import AISettings
from igny8_core.ai.model_registry import ModelRegistry
# Get global settings (for API keys and defaults)
global_settings = GlobalIntegrationSettings.get_instance()
# Get API key from IntegrationProvider
api_key = ModelRegistry.get_api_key('openai')
if not global_settings.openai_api_key:
if not api_key:
raise ValueError(
"Platform OpenAI API key not configured. "
"Please configure GlobalIntegrationSettings in Django admin."
"Please configure IntegrationProvider in Django admin."
)
# Start with global defaults
model = global_settings.openai_model
temperature = global_settings.openai_temperature
api_key = global_settings.openai_api_key # ALWAYS from global
# Get default text model from AIModelConfig
default_model = ModelRegistry.get_default_model('text')
if not default_model:
default_model = 'gpt-4o-mini' # Ultimate fallback
# Get max_tokens from AIModelConfig for the selected model
max_tokens = global_settings.openai_max_tokens # Fallback
model = default_model
# Get settings with account overrides
temperature = AISettings.get_effective_temperature(account)
max_tokens = AISettings.get_effective_max_tokens(account)
# Get max_tokens from AIModelConfig if available
try:
from igny8_core.business.billing.models import AIModelConfig
model_config = AIModelConfig.objects.filter(
@@ -74,60 +79,22 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
except Exception as e:
logger.warning(f"Could not load max_tokens from AIModelConfig for {model}: {e}")
# Check if account has overrides (only for Starter/Growth/Scale plans)
# Free plan users cannot create IntegrationSettings records
try:
account_settings = IntegrationSettings.objects.get(
account=account,
integration_type='openai',
is_active=True
)
config = account_settings.config or {}
# Override model if specified (NULL = use global)
if config.get('model'):
model = config['model']
# Also update max_tokens for the overridden model
try:
from igny8_core.business.billing.models import AIModelConfig
override_config = AIModelConfig.objects.filter(
model_name=model,
is_active=True
).first()
if override_config and override_config.max_output_tokens:
max_tokens = override_config.max_output_tokens
except Exception:
pass
# Override temperature if specified
if config.get('temperature') is not None:
temperature = config['temperature']
# Override max_tokens if explicitly specified (rare case)
if config.get('max_tokens'):
max_tokens = config['max_tokens']
except IntegrationSettings.DoesNotExist:
# No account override, use global defaults (already set above)
pass
except Exception as e:
logger.error(f"Could not load OpenAI settings for account {account.id}: {e}")
raise ValueError(
f"Could not load OpenAI configuration for account {account.id}. "
f"Please configure GlobalIntegrationSettings."
f"Please configure IntegrationProvider and AISettings."
)
# Validate model is in our supported list (optional validation)
# Validate model is in our supported list using ModelRegistry (database-driven)
try:
from igny8_core.utils.ai_processor import MODEL_RATES
if model not in MODEL_RATES:
if not ModelRegistry.validate_model(model):
supported_models = [m.model_name for m in ModelRegistry.list_models(model_type='text')]
logger.warning(
f"Model '{model}' for account {account.id} is not in supported list. "
f"Supported models: {list(MODEL_RATES.keys())}"
f"Supported models: {supported_models}"
)
except ImportError:
except Exception:
pass
# Build response format based on model (JSON mode for supported models)

View File

@@ -181,42 +181,26 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
failed = 0
results = []
# Get image generation settings
# Try account-specific override, otherwise use GlobalIntegrationSettings
# Get image generation settings from AISettings (with account overrides)
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
from igny8_core.modules.system.ai_settings import AISettings
from igny8_core.ai.model_registry import ModelRegistry
config = {}
try:
image_settings = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
logger.info(f"[process_image_generation_queue] Using account {account.id} IntegrationSettings override")
config = image_settings.config or {}
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)}'}
# Get effective settings
image_type = AISettings.get_effective_image_style(account)
image_format = 'webp' # Default format
# 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 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
# Get default image model from database
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
provider = model_config.provider if model_config else 'openai'
model = default_model
else:
model = config.get('model') or config.get('imageModel') or global_settings.dalle_model
provider = 'openai'
model = 'dall-e-3'
logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings")
image_type = config.get('image_type') or global_settings.image_style
image_format = config.get('image_format', 'webp')
# Style to prompt enhancement mapping
# These style descriptors are added to the image prompt for better results
@@ -268,22 +252,15 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info(f" - Featured image size: {featured_image_size}")
logger.info(f" - In-article square: {in_article_square_size}, landscape: {in_article_landscape_size}")
# 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")
# Get provider API key from IntegrationProvider (centralized)
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key from IntegrationProvider")
# 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
# Get API key from IntegrationProvider (centralized)
api_key = ModelRegistry.get_api_key(provider)
if not api_key:
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'}
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not configured in IntegrationProvider")
return {'success': False, 'error': f'{provider.upper()} API key not configured'}
# 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

@@ -145,7 +145,7 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
Dict with 'valid' (bool) and optional 'error' (str)
"""
try:
# Try database first
# Use database-driven validation via AIModelConfig
from igny8_core.business.billing.models import AIModelConfig
exists = AIModelConfig.objects.filter(
@@ -169,29 +169,20 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
else:
return {
'valid': False,
'error': f'Model "{model}" is not found in database'
'error': f'No {model_type} models configured 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:
except Exception as e:
# Log error but don't fallback to constants - DB is authoritative
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error validating model {model}: {e}")
return {
'valid': False,
'error': f'Model "{model}" is not in supported models list'
'error': f'Error validating model: {e}'
}
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]:

View File

@@ -9,6 +9,7 @@ from .account_views import (
UsageAnalyticsViewSet,
DashboardStatsViewSet
)
from igny8_core.modules.system.settings_views import ContentGenerationSettingsViewSet
router = DefaultRouter()
@@ -16,6 +17,10 @@ urlpatterns = [
# Account settings (non-router endpoints for simplified access)
path('settings/', AccountSettingsViewSet.as_view({'get': 'retrieve', 'patch': 'partial_update'}), name='account-settings'),
# AI Settings - Content Generation Settings per the plan
# GET/POST /api/v1/account/settings/ai/
path('settings/ai/', ContentGenerationSettingsViewSet.as_view({'get': 'list', 'post': 'create', 'put': 'create'}), name='ai-settings'),
# Team management
path('team/', TeamManagementViewSet.as_view({'get': 'list', 'post': 'create'}), name='team-list'),
path('team/<int:pk>/', TeamManagementViewSet.as_view({'delete': 'destroy'}), name='team-detail'),

View File

@@ -114,65 +114,48 @@ class CreditUsageLog(AccountBaseModel):
class CreditCostConfig(models.Model):
"""
Token-based credit pricing configuration.
ALL operations use token-to-credit conversion.
Fixed credit costs per operation type.
Per final-model-schemas.md:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| operation_type | CharField(50) PK | Yes | Unique operation ID |
| display_name | CharField(100) | Yes | Human-readable |
| base_credits | IntegerField | Yes | Fixed credits per operation |
| is_active | BooleanField | Yes | Enable/disable |
| description | TextField | No | Admin notes |
"""
# Operation identification
# Operation identification (Primary Key)
operation_type = models.CharField(
max_length=50,
unique=True,
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
help_text="AI operation type"
primary_key=True,
help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')"
)
# Token-to-credit ratio (tokens per 1 credit)
tokens_per_credit = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Number of tokens that equal 1 credit (e.g., 100 tokens = 1 credit)"
# Human-readable name
display_name = models.CharField(
max_length=100,
help_text="Human-readable name"
)
# Minimum credits (for very small token usage)
min_credits = models.IntegerField(
# Fixed credits per operation
base_credits = models.IntegerField(
default=1,
validators=[MinValueValidator(0)],
help_text="Minimum credits to charge regardless of token usage"
help_text="Fixed credits per operation"
)
# Price per credit (for revenue reporting)
price_per_credit_usd = models.DecimalField(
max_digits=10,
decimal_places=4,
default=Decimal('0.01'),
validators=[MinValueValidator(Decimal('0.0001'))],
help_text="USD price per credit (for revenue reporting)"
)
# Metadata
display_name = models.CharField(max_length=100, help_text="Human-readable name")
description = models.TextField(blank=True, help_text="What this operation does")
# Status
is_active = models.BooleanField(default=True, help_text="Enable/disable this operation")
# 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='credit_cost_updates',
help_text="Admin who last updated"
is_active = models.BooleanField(
default=True,
help_text="Enable/disable this operation"
)
# Change tracking
previous_tokens_per_credit = models.IntegerField(
null=True,
# Admin notes
description = models.TextField(
blank=True,
help_text="Tokens per credit before last update (for audit trail)"
help_text="Admin notes about this operation"
)
# History tracking
@@ -186,18 +169,7 @@ class CreditCostConfig(models.Model):
ordering = ['operation_type']
def __str__(self):
return f"{self.display_name} - {self.tokens_per_credit} tokens/credit"
def save(self, *args, **kwargs):
# Track token ratio changes
if self.pk:
try:
old = CreditCostConfig.objects.get(pk=self.pk)
if old.tokens_per_credit != self.tokens_per_credit:
self.previous_tokens_per_credit = old.tokens_per_credit
except CreditCostConfig.DoesNotExist:
pass
super().save(*args, **kwargs)
return f"{self.display_name} - {self.base_credits} credits"
class BillingConfiguration(models.Model):
@@ -696,18 +668,34 @@ class AccountPaymentMethod(AccountBaseModel):
class AIModelConfig(models.Model):
"""
AI Model Configuration - Database-driven model pricing and capabilities.
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
All AI models (text + image) with pricing and credit configuration.
Single Source of Truth for Models.
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
Per final-model-schemas.md:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| id | AutoField PK | Auto | |
| model_name | CharField(100) | Yes | gpt-5.1, dall-e-3, runware:97@1 |
| model_type | CharField(20) | Yes | text / image |
| provider | CharField(50) | Yes | Links to IntegrationProvider |
| display_name | CharField(200) | Yes | Human-readable |
| is_default | BooleanField | Yes | One default per type |
| is_active | BooleanField | Yes | Enable/disable |
| cost_per_1k_input | DecimalField | No | Provider cost (USD) - text models |
| cost_per_1k_output | DecimalField | No | Provider cost (USD) - text models |
| tokens_per_credit | IntegerField | No | Text: tokens per 1 credit (e.g., 1000) |
| credits_per_image | IntegerField | No | Image: credits per image (e.g., 1, 5, 15) |
| quality_tier | CharField(20) | No | basic / quality / premium |
| max_tokens | IntegerField | No | Model token limit |
| context_window | IntegerField | No | Model context size |
| capabilities | JSONField | No | vision, function_calling, etc. |
| created_at | DateTime | Auto | |
| updated_at | DateTime | Auto | |
"""
MODEL_TYPE_CHOICES = [
('text', 'Text Generation'),
('image', 'Image Generation'),
('embedding', 'Embedding'),
]
PROVIDER_CHOICES = [
@@ -717,145 +705,112 @@ class AIModelConfig(models.Model):
('google', 'Google'),
]
QUALITY_TIER_CHOICES = [
('basic', 'Basic'),
('quality', 'Quality'),
('premium', 'Premium'),
]
# 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')"
help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')"
)
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"
help_text="text / image"
)
provider = models.CharField(
max_length=50,
choices=PROVIDER_CHOICES,
db_index=True,
help_text="AI provider (OpenAI, Anthropic, etc.)"
help_text="Links to IntegrationProvider"
)
# 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"
display_name = models.CharField(
max_length=200,
help_text="Human-readable name"
)
is_default = models.BooleanField(
default=False,
db_index=True,
help_text="Mark as default model for its type (only one per type)"
help_text="One default per type"
)
sort_order = models.IntegerField(
default=0,
help_text="Control order in dropdown lists (lower numbers first)"
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Enable/disable"
)
# Metadata
description = models.TextField(
blank=True,
help_text="Admin notes about model usage, strengths, limitations"
)
release_date = models.DateField(
# Text Model Pricing (cost per 1K tokens)
cost_per_1k_input = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
help_text="When model was released/added"
help_text="Provider cost per 1K input tokens (USD) - text models"
)
deprecation_date = models.DateField(
cost_per_1k_output = models.DecimalField(
max_digits=10,
decimal_places=6,
null=True,
blank=True,
help_text="When model will be removed"
help_text="Provider cost per 1K output tokens (USD) - text models"
)
# Audit Fields
# Credit Configuration
tokens_per_credit = models.IntegerField(
null=True,
blank=True,
help_text="Text: tokens per 1 credit (e.g., 1000, 10000)"
)
credits_per_image = models.IntegerField(
null=True,
blank=True,
help_text="Image: credits per image (e.g., 1, 5, 15)"
)
quality_tier = models.CharField(
max_length=20,
choices=QUALITY_TIER_CHOICES,
null=True,
blank=True,
help_text="basic / quality / premium - for image models"
)
# Model Limits
max_tokens = models.IntegerField(
null=True,
blank=True,
help_text="Model token limit"
)
context_window = models.IntegerField(
null=True,
blank=True,
help_text="Model context size"
)
# Capabilities
capabilities = models.JSONField(
default=dict,
blank=True,
help_text="Capabilities: vision, function_calling, json_mode, etc."
)
# Timestamps
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()
@@ -865,7 +820,7 @@ class AIModelConfig(models.Model):
db_table = 'igny8_ai_model_config'
verbose_name = 'AI Model Configuration'
verbose_name_plural = 'AI Model Configurations'
ordering = ['model_type', 'sort_order', 'model_name']
ordering = ['model_type', 'model_name']
indexes = [
models.Index(fields=['model_type', 'is_active']),
models.Index(fields=['provider', 'is_active']),
@@ -878,52 +833,26 @@ class AIModelConfig(models.Model):
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")
@classmethod
def get_default_text_model(cls):
"""Get the default text generation model"""
return cls.objects.filter(model_type='text', is_default=True, is_active=True).first()
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")
@classmethod
def get_default_image_model(cls):
"""Get the default image generation model"""
return cls.objects.filter(model_type='image', is_default=True, is_active=True).first()
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
@classmethod
def get_image_models_by_tier(cls):
"""Get all active image models grouped by quality tier"""
return cls.objects.filter(
model_type='image',
is_active=True
).order_by('quality_tier', 'model_name')

View File

@@ -1,6 +1,8 @@
"""
Credit Service for managing credit transactions and deductions
"""
import math
import logging
from django.db import transaction
from django.utils import timezone
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
@@ -8,10 +10,124 @@ 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
logger = logging.getLogger(__name__)
class CreditService:
"""Service for managing credits - Token-based only"""
@staticmethod
def calculate_credits_for_image(model_name: str, num_images: int = 1) -> int:
"""
Calculate credits for image generation based on AIModelConfig.credits_per_image.
Args:
model_name: The AI model name (e.g., 'dall-e-3', 'flux-1-1-pro')
num_images: Number of images to generate
Returns:
int: Credits required
Raises:
CreditCalculationError: If model not found or has no credits_per_image
"""
from igny8_core.business.billing.models import AIModelConfig
try:
model = AIModelConfig.objects.filter(
model_name=model_name,
is_active=True
).first()
if not model:
raise CreditCalculationError(f"Model {model_name} not found or inactive")
if model.credits_per_image is None:
raise CreditCalculationError(
f"Model {model_name} has no credits_per_image configured"
)
credits = model.credits_per_image * num_images
logger.info(
f"Calculated credits for {model_name}: "
f"{num_images} images × {model.credits_per_image} = {credits} credits"
)
return credits
except AIModelConfig.DoesNotExist:
raise CreditCalculationError(f"Model {model_name} not found")
@staticmethod
def calculate_credits_from_tokens_by_model(model_name: str, total_tokens: int) -> int:
"""
Calculate credits from token usage based on AIModelConfig.tokens_per_credit.
This is the model-specific version that uses the model's configured rate.
For operation-based calculation, use calculate_credits_from_tokens().
Args:
model_name: The AI model name (e.g., 'gpt-4o', 'claude-3-5-sonnet')
total_tokens: Total tokens used (input + output)
Returns:
int: Credits required (minimum 1)
Raises:
CreditCalculationError: If model not found
"""
from igny8_core.business.billing.models import AIModelConfig, BillingConfiguration
try:
model = AIModelConfig.objects.filter(
model_name=model_name,
is_active=True
).first()
if model and model.tokens_per_credit:
tokens_per_credit = model.tokens_per_credit
else:
# Fallback to global default
billing_config = BillingConfiguration.get_config()
tokens_per_credit = billing_config.default_tokens_per_credit
logger.info(
f"Model {model_name} has no tokens_per_credit, "
f"using default: {tokens_per_credit}"
)
if tokens_per_credit <= 0:
raise CreditCalculationError(
f"Invalid tokens_per_credit for {model_name}: {tokens_per_credit}"
)
# Get rounding mode
billing_config = BillingConfiguration.get_config()
rounding_mode = billing_config.credit_rounding_mode
credits_float = total_tokens / tokens_per_credit
if rounding_mode == 'up':
credits = math.ceil(credits_float)
elif rounding_mode == 'down':
credits = math.floor(credits_float)
else: # nearest
credits = round(credits_float)
# Minimum 1 credit
credits = max(credits, 1)
logger.info(
f"Calculated credits for {model_name}: "
f"{total_tokens} tokens ÷ {tokens_per_credit} = {credits} credits"
)
return credits
except Exception as e:
logger.error(f"Error calculating credits for {model_name}: {e}")
raise CreditCalculationError(f"Error calculating credits: {e}")
@staticmethod
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
"""
@@ -324,3 +440,55 @@ class CreditService:
return account.credits
@staticmethod
@transaction.atomic
def deduct_credits_for_image(
account,
model_name: str,
num_images: int = 1,
description: str = None,
metadata: dict = None,
cost_usd: float = None,
related_object_type: str = None,
related_object_id: int = None
):
"""
Deduct credits for image generation based on model's credits_per_image.
Args:
account: Account instance
model_name: AI model used (e.g., 'dall-e-3', 'flux-1-1-pro')
num_images: Number of images generated
description: Optional description
metadata: Optional metadata dict
cost_usd: Optional cost in USD
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
"""
credits_required = CreditService.calculate_credits_for_image(model_name, num_images)
if account.credits < credits_required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
)
if not description:
description = f"Image generation: {num_images} images with {model_name} = {credits_required} credits"
return CreditService.deduct_credits(
account=account,
amount=credits_required,
operation_type='image_generation',
description=description,
metadata=metadata,
cost_usd=cost_usd,
model_used=model_name,
tokens_input=None,
tokens_output=None,
related_object_type=related_object_type,
related_object_id=related_object_id
)

View File

@@ -552,19 +552,18 @@ class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin):
@admin.register(CreditCostConfig)
class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
"""
Admin for Credit Cost Configuration.
Per final-model-schemas.md - Fixed credits per operation type.
"""
list_display = [
'operation_type',
'display_name',
'tokens_per_credit_display',
'price_per_credit_usd',
'min_credits',
'is_active',
'cost_change_indicator',
'updated_at',
'updated_by'
'base_credits_display',
'is_active_icon',
]
list_filter = ['is_active', 'updated_at']
list_filter = ['is_active']
search_fields = ['operation_type', 'display_name', 'description']
actions = ['bulk_activate', 'bulk_deactivate']
@@ -572,60 +571,30 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
('Operation', {
'fields': ('operation_type', 'display_name', 'description')
}),
('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_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
('Credits', {
'fields': ('base_credits', 'is_active'),
'description': 'Fixed credits charged per operation'
}),
)
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
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' # Cheap (high tokens per credit)
def base_credits_display(self, obj):
"""Show base credits with formatting"""
return format_html(
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
color,
obj.tokens_per_credit
'<span style="font-weight: bold;">{} credits</span>',
obj.base_credits
)
tokens_per_credit_display.short_description = 'Token Ratio'
def cost_change_indicator(self, obj):
"""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.tokens_per_credit > obj.previous_tokens_per_credit:
icon = '📉' # Cheaper (more tokens per credit)
color = 'green'
else:
icon = '➡️' # Same
color = 'gray'
base_credits_display.short_description = 'Credits'
def is_active_icon(self, obj):
"""Active status icon"""
if obj.is_active:
return format_html(
'{} <span style="color: {};">({}{})</span>',
icon,
color,
obj.previous_tokens_per_credit,
obj.tokens_per_credit
'<span style="color: green; font-size: 18px;" title="Active">●</span>'
)
return ''
cost_change_indicator.short_description = 'Recent Change'
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)
return format_html(
'<span style="color: red; font-size: 18px;" title="Inactive">●</span>'
)
is_active_icon.short_description = 'Active'
@admin.action(description='Activate selected configurations')
def bulk_activate(self, request, queryset):
@@ -763,67 +732,60 @@ class BillingConfigurationAdmin(Igny8ModelAdmin):
@admin.register(AIModelConfig)
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
"""
Admin for AI Model Configuration - Database-driven model pricing
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES
Admin for AI Model Configuration - Single Source of Truth for Models.
Per final-model-schemas.md
"""
list_display = [
'model_name',
'display_name_short',
'model_type_badge',
'provider_badge',
'pricing_display',
'credit_display',
'quality_tier',
'is_active_icon',
'is_default_icon',
'sort_order',
'updated_at',
]
list_filter = [
'model_type',
'provider',
'quality_tier',
'is_active',
'is_default',
'supports_json_mode',
'supports_vision',
'supports_function_calling',
]
search_fields = ['model_name', 'display_name', 'description']
search_fields = ['model_name', 'display_name']
ordering = ['model_type', 'sort_order', 'model_name']
ordering = ['model_type', 'model_name']
readonly_fields = ['created_at', 'updated_at', 'updated_by']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Information', {
'fields': ('model_name', 'display_name', 'model_type', 'provider', 'description'),
'description': 'Core model identification and classification'
'fields': ('model_name', 'model_type', 'provider', 'display_name'),
'description': 'Core model identification'
}),
('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)',
'fields': ('cost_per_1k_input', 'cost_per_1k_output', 'tokens_per_credit', 'max_tokens', 'context_window'),
'description': 'For TEXT models only',
'classes': ('collapse',)
}),
('Image Model Pricing', {
'fields': ('cost_per_image', 'valid_sizes'),
'description': 'Pricing and configuration for IMAGE models only (leave blank for text models)',
'fields': ('credits_per_image', 'quality_tier'),
'description': 'For IMAGE models only',
'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',
'fields': ('capabilities',),
'description': 'JSON: vision, function_calling, json_mode, etc.',
'classes': ('collapse',)
}),
('Audit Trail', {
'fields': ('created_at', 'updated_at', 'updated_by'),
('Status', {
'fields': ('is_active', 'is_default'),
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
@@ -831,8 +793,8 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
# 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] + '...'
if len(obj.display_name) > 40:
return obj.display_name[:37] + '...'
return obj.display_name
display_name_short.short_description = 'Display Name'
@@ -841,7 +803,6 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
colors = {
'text': '#3498db', # Blue
'image': '#e74c3c', # Red
'embedding': '#2ecc71', # Green
}
color = colors.get(obj.model_type, '#95a5a6')
return format_html(
@@ -855,10 +816,10 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
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
'openai': '#10a37f',
'anthropic': '#d97757',
'runware': '#6366f1',
'google': '#4285f4',
}
color = colors.get(obj.provider, '#95a5a6')
return format_html(
@@ -869,23 +830,20 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
)
provider_badge.short_description = 'Provider'
def pricing_display(self, obj):
"""Format pricing based on model type"""
if obj.model_type == 'text':
def credit_display(self, obj):
"""Format credit info based on model type"""
if obj.model_type == 'text' and obj.tokens_per_credit:
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} / ${} per 1M</span>',
obj.input_cost_per_1m,
obj.output_cost_per_1m
'<span style="font-family: monospace;">{} tokens/credit</span>',
obj.tokens_per_credit
)
elif obj.model_type == 'image':
elif obj.model_type == 'image' and obj.credits_per_image:
return format_html(
'<span style="color: #2c3e50; font-family: monospace;">'
'${} per image</span>',
obj.cost_per_image
'<span style="font-family: monospace;">{} credits/image</span>',
obj.credits_per_image
)
return '-'
pricing_display.short_description = 'Pricing'
credit_display.short_description = 'Credits'
def is_active_icon(self, obj):
"""Active status icon"""
@@ -915,41 +873,27 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
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
)
self.message_user(request, f'{count} model(s) activated.', 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
)
self.message_user(request, f'{count} model(s) deactivated.', 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
)
self.message_user(request, 'Select exactly one model.', 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()
@@ -958,9 +902,4 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
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)
set_as_default.short_description = 'Set as default model'

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.9 on 2026-01-04 06:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0024_update_image_models_v2'),
]
operations = [
migrations.AddField(
model_name='aimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Fixed credits per image generated. For image models only. (e.g., 1, 5, 15)', null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='Quality tier for frontend UI display (Basic/Quality/Premium). For image models.', max_length=20, null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Number of tokens that equal 1 credit. For text models only. (e.g., 1000, 10000)', null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Fixed credits per image generated. For image models only. (e.g., 1, 5, 15)', null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='Quality tier for frontend UI display (Basic/Quality/Premium). For image models.', max_length=20, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Number of tokens that equal 1 credit. For text models only. (e.g., 1000, 10000)', null=True),
),
]

View File

@@ -0,0 +1,63 @@
# Generated manually for data migration
from django.db import migrations
def populate_aimodel_credit_fields(apps, schema_editor):
"""
Populate credit calculation fields in AIModelConfig.
- Text models: tokens_per_credit (how many tokens = 1 credit)
- Image models: credits_per_image (fixed credits per image) + quality_tier
"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
# Text models: tokens_per_credit
text_model_credits = {
'gpt-4o-mini': 10000, # Cheap model: 10k tokens = 1 credit
'gpt-4o': 1000, # Premium model: 1k tokens = 1 credit
'gpt-5.1': 1000, # Default model: 1k tokens = 1 credit
'gpt-5.2': 1000, # Future model
'gpt-4.1': 1000, # Legacy
'gpt-4-turbo-preview': 500, # Expensive
}
for model_name, tokens_per_credit in text_model_credits.items():
AIModelConfig.objects.filter(
model_name=model_name,
model_type='text'
).update(tokens_per_credit=tokens_per_credit)
# Image models: credits_per_image + quality_tier
image_model_credits = {
'runware:97@1': {'credits_per_image': 1, 'quality_tier': 'basic'}, # Basic - cheap
'dall-e-3': {'credits_per_image': 5, 'quality_tier': 'quality'}, # Quality - mid
'google:4@2': {'credits_per_image': 15, 'quality_tier': 'premium'}, # Premium - expensive
'dall-e-2': {'credits_per_image': 2, 'quality_tier': 'basic'}, # Legacy
}
for model_name, credits_data in image_model_credits.items():
AIModelConfig.objects.filter(
model_name=model_name,
model_type='image'
).update(**credits_data)
def reverse_migration(apps, schema_editor):
"""Clear credit fields"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
AIModelConfig.objects.all().update(
tokens_per_credit=None,
credits_per_image=None,
quality_tier=None
)
class Migration(migrations.Migration):
dependencies = [
('billing', '0025_add_aimodel_credit_fields'),
]
operations = [
migrations.RunPython(populate_aimodel_credit_fields, reverse_migration),
]

View File

@@ -0,0 +1,356 @@
# Generated by Django 5.2.9 on 2026-01-04 10:40
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0026_populate_aimodel_credits'),
]
operations = [
migrations.AlterModelOptions(
name='aimodelconfig',
options={'ordering': ['model_type', 'model_name'], 'verbose_name': 'AI Model Configuration', 'verbose_name_plural': 'AI Model Configurations'},
),
migrations.RemoveField(
model_name='aimodelconfig',
name='cost_per_image',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='deprecation_date',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='description',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='input_cost_per_1m',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='max_output_tokens',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='output_cost_per_1m',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='release_date',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='sort_order',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='supports_function_calling',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='supports_json_mode',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='supports_vision',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='updated_by',
),
migrations.RemoveField(
model_name='aimodelconfig',
name='valid_sizes',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='created_at',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='id',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='min_credits',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='previous_tokens_per_credit',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='price_per_credit_usd',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='tokens_per_credit',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='updated_at',
),
migrations.RemoveField(
model_name='creditcostconfig',
name='updated_by',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='cost_per_image',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='deprecation_date',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='description',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='input_cost_per_1m',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='max_output_tokens',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='output_cost_per_1m',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='release_date',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='sort_order',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='supports_function_calling',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='supports_json_mode',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='supports_vision',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='updated_by',
),
migrations.RemoveField(
model_name='historicalaimodelconfig',
name='valid_sizes',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='created_at',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='id',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='min_credits',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='previous_tokens_per_credit',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='price_per_credit_usd',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='tokens_per_credit',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='updated_at',
),
migrations.RemoveField(
model_name='historicalcreditcostconfig',
name='updated_by',
),
migrations.AddField(
model_name='aimodelconfig',
name='capabilities',
field=models.JSONField(blank=True, default=dict, help_text='Capabilities: vision, function_calling, json_mode, etc.'),
),
migrations.AddField(
model_name='aimodelconfig',
name='cost_per_1k_input',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K input tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='cost_per_1k_output',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K output tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='aimodelconfig',
name='max_tokens',
field=models.IntegerField(blank=True, help_text='Model token limit', null=True),
),
migrations.AddField(
model_name='creditcostconfig',
name='base_credits',
field=models.IntegerField(default=1, help_text='Fixed credits per operation', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='capabilities',
field=models.JSONField(blank=True, default=dict, help_text='Capabilities: vision, function_calling, json_mode, etc.'),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='cost_per_1k_input',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K input tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='cost_per_1k_output',
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K output tokens (USD) - text models', max_digits=10, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='max_tokens',
field=models.IntegerField(blank=True, help_text='Model token limit', null=True),
),
migrations.AddField(
model_name='historicalcreditcostconfig',
name='base_credits',
field=models.IntegerField(default=1, help_text='Fixed credits per operation', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='aimodelconfig',
name='context_window',
field=models.IntegerField(blank=True, help_text='Model context size', null=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Image: credits per image (e.g., 1, 5, 15)', null=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='display_name',
field=models.CharField(help_text='Human-readable name', max_length=200),
),
migrations.AlterField(
model_name='aimodelconfig',
name='is_active',
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable'),
),
migrations.AlterField(
model_name='aimodelconfig',
name='is_default',
field=models.BooleanField(db_index=True, default=False, help_text='One default per type'),
),
migrations.AlterField(
model_name='aimodelconfig',
name='model_name',
field=models.CharField(db_index=True, help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')", max_length=100, unique=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='model_type',
field=models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation')], db_index=True, help_text='text / image', max_length=20),
),
migrations.AlterField(
model_name='aimodelconfig',
name='provider',
field=models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='Links to IntegrationProvider', max_length=50),
),
migrations.AlterField(
model_name='aimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
),
migrations.AlterField(
model_name='aimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Text: tokens per 1 credit (e.g., 1000, 10000)', null=True),
),
migrations.AlterField(
model_name='creditcostconfig',
name='description',
field=models.TextField(blank=True, help_text='Admin notes about this operation'),
),
migrations.AlterField(
model_name='creditcostconfig',
name='operation_type',
field=models.CharField(help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')", max_length=50, primary_key=True, serialize=False, unique=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='context_window',
field=models.IntegerField(blank=True, help_text='Model context size', null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='credits_per_image',
field=models.IntegerField(blank=True, help_text='Image: credits per image (e.g., 1, 5, 15)', null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='display_name',
field=models.CharField(help_text='Human-readable name', max_length=200),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='is_active',
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable'),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='is_default',
field=models.BooleanField(db_index=True, default=False, help_text='One default per type'),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='model_name',
field=models.CharField(db_index=True, help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')", max_length=100),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='model_type',
field=models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation')], db_index=True, help_text='text / image', max_length=20),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='provider',
field=models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='Links to IntegrationProvider', max_length=50),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='tokens_per_credit',
field=models.IntegerField(blank=True, help_text='Text: tokens per 1 credit (e.g., 1000, 10000)', null=True),
),
migrations.AlterField(
model_name='historicalcreditcostconfig',
name='description',
field=models.TextField(blank=True, help_text='Admin notes about this operation'),
),
migrations.AlterField(
model_name='historicalcreditcostconfig',
name='operation_type',
field=models.CharField(db_index=True, help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')", max_length=50),
),
]

View File

@@ -255,6 +255,23 @@ class AIModelConfigSerializer(serializers.Serializer):
)
valid_sizes = serializers.ListField(read_only=True, allow_null=True)
# Credit calculation fields (NEW)
credits_per_image = serializers.IntegerField(
read_only=True,
allow_null=True,
help_text="Credits charged per image generation"
)
tokens_per_credit = serializers.IntegerField(
read_only=True,
allow_null=True,
help_text="Tokens per credit for text models"
)
quality_tier = serializers.CharField(
read_only=True,
allow_null=True,
help_text="Quality tier: basic, quality, or premium"
)
# Capabilities
supports_json_mode = serializers.BooleanField(read_only=True)
supports_vision = serializers.BooleanField(read_only=True)

View File

@@ -789,7 +789,7 @@ class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
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')
return queryset.order_by('model_type', 'model_name')
def get_serializer_class(self):
"""Return serializer class"""

View File

@@ -12,8 +12,10 @@ __all__ = [
'Strategy',
# Global settings models
'GlobalIntegrationSettings',
'AccountIntegrationOverride',
'GlobalAIPrompt',
'GlobalAuthorProfile',
'GlobalStrategy',
# New centralized models
'IntegrationProvider',
'AISettings',
]

View File

@@ -32,7 +32,7 @@ class AIPromptResource(resources.ModelResource):
# Import settings admin
from .settings_admin import (
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
ModuleSettingsAdmin, AISettingsAdmin
ModuleSettingsAdmin
)
try:
@@ -587,3 +587,112 @@ class GlobalModuleSettingsAdmin(Igny8ModelAdmin):
'updated_at',
]
# IntegrationProvider Admin (centralized API keys)
from .models import IntegrationProvider
@admin.register(IntegrationProvider)
class IntegrationProviderAdmin(Igny8ModelAdmin):
"""
Admin for IntegrationProvider - Centralized API key management.
Per final-model-schemas.md
"""
list_display = [
'provider_id',
'display_name',
'provider_type',
'is_active',
'is_sandbox',
'has_api_key',
'updated_at',
]
list_filter = ['provider_type', 'is_active', 'is_sandbox']
search_fields = ['provider_id', 'display_name']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Provider Info', {
'fields': ('provider_id', 'display_name', 'provider_type')
}),
('API Configuration', {
'fields': ('api_key', 'api_secret', 'webhook_secret', 'api_endpoint'),
'description': 'Enter API keys and endpoints. These are platform-wide.'
}),
('Extra Config', {
'fields': ('config',),
'classes': ('collapse',),
'description': 'JSON config for provider-specific settings'
}),
('Status', {
'fields': ('is_active', 'is_sandbox')
}),
('Metadata', {
'fields': ('updated_by', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def has_api_key(self, obj):
"""Show if API key is configured"""
return bool(obj.api_key)
has_api_key.boolean = True
has_api_key.short_description = 'API Key Set'
def save_model(self, request, obj, form, change):
"""Set updated_by to current user"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)
# SystemAISettings Admin (new simplified AI settings)
from .ai_settings import SystemAISettings
@admin.register(SystemAISettings)
class SystemAISettingsAdmin(Igny8ModelAdmin):
"""
Admin for SystemAISettings - System-wide AI defaults (Singleton).
Per final-model-schemas.md
"""
list_display = [
'id',
'temperature',
'max_tokens',
'image_style',
'image_quality',
'max_images_per_article',
'updated_at',
]
readonly_fields = ['updated_at']
fieldsets = (
('AI Parameters', {
'fields': ('temperature', 'max_tokens'),
'description': 'System-wide defaults for AI text generation. Accounts can override via AccountSettings.'
}),
('Image Generation', {
'fields': ('image_style', 'image_quality', 'max_images_per_article', 'image_size'),
'description': 'System-wide defaults for image generation. Accounts can override via AccountSettings.'
}),
('Metadata', {
'fields': ('updated_by', 'updated_at'),
'classes': ('collapse',)
}),
)
def has_add_permission(self, request):
"""Only allow one instance (singleton)"""
return not SystemAISettings.objects.exists()
def has_delete_permission(self, request, obj=None):
"""Prevent deletion of singleton"""
return False
def save_model(self, request, obj, form, change):
"""Set updated_by to current user"""
obj.updated_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,195 @@
"""
AI Settings - System-wide AI defaults (Singleton)
This is the clean, simplified model for AI configuration.
Replaces the deprecated GlobalIntegrationSettings.
API keys are stored in IntegrationProvider.
Model definitions are in AIModelConfig.
This model only stores system-wide defaults for AI parameters.
"""
from django.db import models
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class SystemAISettings(models.Model):
"""
System-wide AI defaults. Singleton (pk=1).
Removed fields (now elsewhere):
- All *_api_key fields → IntegrationProvider
- All *_model fields → AIModelConfig.is_default
- default_text_provider → AIModelConfig.is_default where model_type='text'
- default_image_service → AIModelConfig.is_default where model_type='image'
Accounts can override these via AccountSettings with keys like:
- ai.temperature
- ai.max_tokens
- ai.image_style
- ai.image_quality
- ai.max_images
"""
IMAGE_STYLE_CHOICES = [
('photorealistic', 'Photorealistic'),
('illustration', 'Illustration'),
('3d_render', '3D Render'),
('minimal_flat', 'Minimal / Flat Design'),
('artistic', 'Artistic / Painterly'),
('cartoon', 'Cartoon / Stylized'),
]
IMAGE_QUALITY_CHOICES = [
('standard', 'Standard'),
('hd', 'HD'),
]
IMAGE_SIZE_CHOICES = [
('1024x1024', '1024x1024 (Square)'),
('1792x1024', '1792x1024 (Landscape)'),
('1024x1792', '1024x1792 (Portrait)'),
]
# AI Parameters
temperature = models.FloatField(
default=0.7,
help_text="AI temperature (0.0-2.0). Higher = more creative."
)
max_tokens = models.IntegerField(
default=8192,
help_text="Max response tokens"
)
# Image Generation Settings
image_style = models.CharField(
max_length=30,
default='photorealistic',
choices=IMAGE_STYLE_CHOICES,
help_text="Default image style"
)
image_quality = models.CharField(
max_length=20,
default='standard',
choices=IMAGE_QUALITY_CHOICES,
help_text="Default image quality (standard/hd)"
)
max_images_per_article = models.IntegerField(
default=4,
help_text="Max in-article images (1-8)"
)
image_size = models.CharField(
max_length=20,
default='1024x1024',
choices=IMAGE_SIZE_CHOICES,
help_text="Default image dimensions"
)
# Metadata
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='system_ai_settings_updates'
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_system_ai_settings'
verbose_name = 'System AI Settings'
verbose_name_plural = 'System AI Settings'
def save(self, *args, **kwargs):
"""Enforce singleton - always use pk=1"""
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Prevent deletion of singleton"""
pass
@classmethod
def get_instance(cls):
"""Get or create the singleton instance"""
obj, created = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return "System AI Settings"
# Helper methods for getting effective settings with account overrides
@classmethod
def get_effective_temperature(cls, account=None) -> float:
"""Get temperature, checking account override first"""
if account:
override = cls._get_account_override(account, 'ai.temperature')
if override is not None:
return float(override)
return cls.get_instance().temperature
@classmethod
def get_effective_max_tokens(cls, account=None) -> int:
"""Get max_tokens, checking account override first"""
if account:
override = cls._get_account_override(account, 'ai.max_tokens')
if override is not None:
return int(override)
return cls.get_instance().max_tokens
@classmethod
def get_effective_image_style(cls, account=None) -> str:
"""Get image_style, checking account override first"""
if account:
override = cls._get_account_override(account, 'ai.image_style')
if override is not None:
return str(override)
return cls.get_instance().image_style
@classmethod
def get_effective_image_quality(cls, account=None) -> str:
"""Get image_quality, checking account override first"""
if account:
override = cls._get_account_override(account, 'ai.image_quality')
if override is not None:
return str(override)
return cls.get_instance().image_quality
@classmethod
def get_effective_max_images(cls, account=None) -> int:
"""Get max_images_per_article, checking account override first"""
if account:
override = cls._get_account_override(account, 'ai.max_images')
if override is not None:
return int(override)
return cls.get_instance().max_images_per_article
@classmethod
def get_effective_image_size(cls, account=None) -> str:
"""Get image_size, checking account override first"""
if account:
override = cls._get_account_override(account, 'ai.image_size')
if override is not None:
return str(override)
return cls.get_instance().image_size
@staticmethod
def _get_account_override(account, key: str):
"""Get account-specific override from AccountSettings"""
try:
from igny8_core.modules.system.settings_models import AccountSettings
setting = AccountSettings.objects.filter(
account=account,
key=key
).first()
if setting and setting.config:
return setting.config.get('value')
except Exception as e:
logger.debug(f"Could not get account override for {key}: {e}")
return None
# Alias for backward compatibility and clearer naming
AISettings = SystemAISettings

View File

@@ -21,7 +21,7 @@ def get_text_model_choices():
models = AIModelConfig.objects.filter(
model_type='text',
is_active=True
).order_by('sort_order', 'model_name')
).order_by('model_name')
if models.exists():
return [(m.model_name, m.display_name) for m in models]
@@ -48,7 +48,7 @@ def get_image_model_choices(provider=None):
)
if provider:
qs = qs.filter(provider=provider)
qs = qs.order_by('sort_order', 'model_name')
qs = qs.order_by('model_name')
if qs.exists():
return [(m.model_name, m.display_name) for m in qs]

View File

@@ -109,16 +109,15 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
)
try:
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
from igny8_core.ai.model_registry import ModelRegistry
# Get platform API keys
global_settings = GlobalIntegrationSettings.get_instance()
# Get platform API keys from IntegrationProvider (centralized)
api_key = ModelRegistry.get_api_key(integration_type)
# Get config from request (model selection)
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
if integration_type == 'openai':
api_key = global_settings.openai_api_key
if not api_key:
return error_response(
error='Platform OpenAI API key not configured. Please contact administrator.',
@@ -128,7 +127,6 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
return self._test_openai(api_key, config, request)
elif integration_type == 'runware':
api_key = global_settings.runware_api_key
if not api_key:
return error_response(
error='Platform Runware API key not configured. Please contact administrator.',
@@ -212,10 +210,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
output_tokens = usage.get('completion_tokens', 0)
total_tokens = usage.get('total_tokens', 0)
# Calculate cost using model rates (reference plugin: line 274-275)
from igny8_core.utils.ai_processor import MODEL_RATES
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000
# Calculate cost using ModelRegistry (database-driven)
from igny8_core.ai.model_registry import ModelRegistry
cost = float(ModelRegistry.calculate_cost(
model,
input_tokens=input_tokens,
output_tokens=output_tokens
))
return success_response(
data={
@@ -521,31 +522,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
request=request
)
# Get API key from saved settings for the specified provider only
# Get API key from IntegrationProvider (centralized, platform-wide)
logger.info(f"[generate_image] Step 3: Getting API key for provider: {provider}")
from .models import IntegrationSettings
from igny8_core.ai.model_registry import ModelRegistry
# Only fetch settings for the specified provider
api_key = None
integration_enabled = False
integration_type = provider # 'openai' or 'runware'
try:
integration_settings = IntegrationSettings.objects.get(
integration_type=integration_type,
account=account
)
api_key = integration_settings.config.get('apiKey')
integration_enabled = integration_settings.is_active
logger.info(f"[generate_image] {integration_type.upper()} settings found: enabled={integration_enabled}, has_key={bool(api_key)}")
except IntegrationSettings.DoesNotExist:
logger.warning(f"[generate_image] {integration_type.upper()} settings not found in database")
api_key = None
integration_enabled = False
except Exception as e:
logger.error(f"[generate_image] Error getting {integration_type.upper()} settings: {e}")
api_key = None
integration_enabled = False
api_key = ModelRegistry.get_api_key(provider)
integration_enabled = api_key is not None
logger.info(f"[generate_image] {provider.upper()} API key: enabled={integration_enabled}, has_key={bool(api_key)}")
# Validate provider and API key
logger.info(f"[generate_image] Step 4: Validating {provider} provider and API key")
@@ -635,8 +618,8 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
def save_settings(self, request, pk=None):
"""
Save integration settings (account overrides only).
- Saves model/parameter overrides to IntegrationSettings
- NEVER saves API keys (those are platform-wide)
- Saves model/parameter overrides to AccountSettings (key-value store)
- NEVER saves API keys (those are platform-wide via IntegrationProvider)
- Free plan: Should be blocked at frontend level
"""
integration_type = pk
@@ -689,62 +672,47 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
# TODO: Check if Free plan - they shouldn't be able to save overrides
# This should be blocked at frontend level, but add backend check too
from .models import IntegrationSettings
from .settings_models import AccountSettings
# Build clean config with only allowed overrides
clean_config = {}
# Save account overrides to AccountSettings (key-value store)
saved_keys = []
if integration_type == 'openai':
# Only allow model, temperature, max_tokens overrides
if 'model' in config:
clean_config['model'] = config['model']
if 'temperature' in config:
clean_config['temperature'] = config['temperature']
if 'max_tokens' in config:
clean_config['max_tokens'] = config['max_tokens']
# Save OpenAI-specific overrides to AccountSettings
key_mappings = {
'temperature': 'ai.temperature',
'max_tokens': 'ai.max_tokens',
}
for config_key, account_key in key_mappings.items():
if config_key in config:
AccountSettings.objects.update_or_create(
account=account,
key=account_key,
defaults={'config': {'value': config[config_key]}}
)
saved_keys.append(account_key)
elif integration_type == 'image_generation':
# Map service to provider if service is provided
if 'service' in config:
clean_config['service'] = config['service']
clean_config['provider'] = config['service']
if 'provider' in config:
clean_config['provider'] = config['provider']
clean_config['service'] = config['provider']
# Model selection (service-specific)
if 'model' in config:
clean_config['model'] = config['model']
if 'imageModel' in config:
clean_config['imageModel'] = config['imageModel']
clean_config['model'] = config['imageModel'] # Also store in 'model' for consistency
if 'runwareModel' in config:
clean_config['runwareModel'] = config['runwareModel']
# Universal image settings (applies to all providers)
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
'desktop_enabled', 'featured_image_size', 'desktop_image_size']:
if key in config:
clean_config[key] = config[key]
# Get or create integration settings
logger.info(f"[save_settings] Saving clean config: {clean_config}")
integration_settings, created = IntegrationSettings.objects.get_or_create(
integration_type=integration_type,
# Save image generation overrides to AccountSettings
key_mappings = {
'image_type': 'ai.image_style',
'image_style': 'ai.image_style',
'image_quality': 'ai.image_quality',
'max_in_article_images': 'ai.max_images',
'desktop_image_size': 'ai.image_size',
}
for config_key, account_key in key_mappings.items():
if config_key in config:
AccountSettings.objects.update_or_create(
account=account,
defaults={'config': clean_config, 'is_active': True}
key=account_key,
defaults={'config': {'value': config[config_key]}}
)
logger.info(f"[save_settings] Result: created={created}, id={integration_settings.id}")
saved_keys.append(account_key)
if not created:
integration_settings.config = clean_config
integration_settings.is_active = True
integration_settings.save()
logger.info(f"[save_settings] Updated existing settings")
logger.info(f"[save_settings] Successfully saved overrides for {integration_type}")
logger.info(f"[save_settings] Saved to AccountSettings: {saved_keys}")
return success_response(
data={'config': clean_config},
data={'saved_keys': saved_keys},
message=f'{integration_type.upper()} settings saved successfully',
request=request
)
@@ -787,20 +755,20 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
logger.warning(f"Error getting account from user: {e}")
account = None
from .models import IntegrationSettings
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
from igny8_core.modules.system.ai_settings import SystemAISettings
from igny8_core.ai.model_registry import ModelRegistry
# Get global defaults
global_settings = GlobalIntegrationSettings.get_instance()
# Build response with global defaults
# Build response using SystemAISettings (singleton) + AccountSettings overrides
if integration_type == 'openai':
# Get max_tokens from AIModelConfig for the selected model
max_tokens = global_settings.openai_max_tokens # Fallback
# Get default model from AIModelConfig
default_model = ModelRegistry.get_default_model('text') or 'gpt-4o-mini'
# Get max_tokens from AIModelConfig for the model
max_tokens = SystemAISettings.get_effective_max_tokens(account)
try:
from igny8_core.business.billing.models import AIModelConfig
model_config = AIModelConfig.objects.filter(
model_name=global_settings.openai_model,
model_name=default_model,
is_active=True
).first()
if model_config and model_config.max_output_tokens:
@@ -811,31 +779,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
response_data = {
'id': 'openai',
'enabled': True, # Always enabled (platform-wide)
'model': global_settings.openai_model,
'temperature': global_settings.openai_temperature,
'model': default_model,
'temperature': SystemAISettings.get_effective_temperature(account),
'max_tokens': max_tokens,
'using_global': True, # Flag to show it's using global
}
# Check for account overrides
if account:
try:
integration_settings = IntegrationSettings.objects.get(
integration_type=integration_type,
account=account,
is_active=True
)
config = integration_settings.config or {}
if config.get('model'):
response_data['model'] = config['model']
response_data['using_global'] = False
if config.get('temperature') is not None:
response_data['temperature'] = config['temperature']
if config.get('max_tokens'):
response_data['max_tokens'] = config['max_tokens']
except IntegrationSettings.DoesNotExist:
pass
elif integration_type == 'runware':
response_data = {
'id': 'runware',
@@ -851,63 +800,35 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
'google:4@2': '1376x768',
}
# Get default service and model based on global settings
default_service = global_settings.default_image_service
default_model = global_settings.dalle_model if default_service == 'openai' else global_settings.runware_model
# Get default image model from AIModelConfig
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
default_service = model_config.provider if model_config else 'openai'
else:
default_service = 'openai'
default_model = 'dall-e-3'
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(default_model, '1280x768')
response_data = {
'id': 'image_generation',
'enabled': True,
'service': default_service, # From global settings
'provider': default_service, # Alias for service
'model': default_model, # Service-specific default model
'imageModel': global_settings.dalle_model, # OpenAI model
'runwareModel': global_settings.runware_model, # Runware model
'image_type': global_settings.image_style, # Use image_style as default
'image_quality': global_settings.image_quality, # Universal quality
'image_style': global_settings.image_style, # Universal style
'max_in_article_images': global_settings.max_in_article_images,
'service': default_service,
'provider': default_service,
'model': default_model,
'imageModel': default_model if default_service == 'openai' else 'dall-e-3',
'runwareModel': default_model if default_service != 'openai' else None,
'image_type': SystemAISettings.get_effective_image_style(account),
'image_quality': SystemAISettings.get_effective_image_quality(account),
'image_style': SystemAISettings.get_effective_image_style(account),
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
'image_format': 'webp',
'desktop_enabled': True,
'featured_image_size': model_landscape_size, # Model-specific landscape
'desktop_image_size': global_settings.desktop_image_size,
'featured_image_size': model_landscape_size,
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
'using_global': True,
}
# Check for account overrides
if account:
try:
integration_settings = IntegrationSettings.objects.get(
integration_type=integration_type,
account=account,
is_active=True
)
config = integration_settings.config or {}
# Override with account settings
if config:
response_data['using_global'] = False
# Service/provider
if 'service' in config:
response_data['service'] = config['service']
response_data['provider'] = config['service']
if 'provider' in config:
response_data['provider'] = config['provider']
response_data['service'] = config['provider']
# Models
if 'model' in config:
response_data['model'] = config['model']
if 'imageModel' in config:
response_data['imageModel'] = config['imageModel']
if 'runwareModel' in config:
response_data['runwareModel'] = config['runwareModel']
# Universal image settings
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
'desktop_enabled', 'featured_image_size', 'desktop_image_size']:
if key in config:
response_data[key] = config[key]
except IntegrationSettings.DoesNotExist:
pass
else:
# Other integration types - return empty
response_data = {
@@ -932,14 +853,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
"""Get image generation settings for current account.
Architecture:
1. If account has IntegrationSettings override -> use it (with GlobalIntegrationSettings as fallback for missing fields)
2. Otherwise -> use GlobalIntegrationSettings (platform-wide defaults)
Note: API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys).
Account IntegrationSettings only store model/parameter overrides.
1. SystemAISettings (singleton) provides system-wide defaults
2. AccountSettings (key-value) provides per-account overrides
3. API keys come from IntegrationProvider (accounts cannot override API keys)
"""
from .models import IntegrationSettings
from .global_settings_models import GlobalIntegrationSettings
from igny8_core.modules.system.ai_settings import SystemAISettings
from igny8_core.ai.model_registry import ModelRegistry
account = getattr(request, 'account', None)
@@ -949,10 +868,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
# Get GlobalIntegrationSettings (platform defaults - always available)
global_settings = GlobalIntegrationSettings.get_instance()
# Model-specific landscape sizes (from GlobalIntegrationSettings)
# Model-specific landscape sizes
MODEL_LANDSCAPE_SIZES = {
'runware:97@1': '1280x768', # Hi Dream Full landscape
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
@@ -962,53 +878,38 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
}
try:
# Check if account has specific overrides
account_config = {}
if account:
try:
integration = IntegrationSettings.objects.get(
account=account,
integration_type='image_generation',
is_active=True
)
account_config = integration.config or {}
logger.info(f"[get_image_generation_settings] Found account {account.id} override: {list(account_config.keys())}")
except IntegrationSettings.DoesNotExist:
logger.info(f"[get_image_generation_settings] No override for account {account.id if account else 'None'}, using GlobalIntegrationSettings")
# Build response using account overrides with global fallbacks
provider = account_config.get('provider') or global_settings.default_image_service
# Get model based on provider
if provider == 'runware':
model = account_config.get('model') or account_config.get('imageModel') or global_settings.runware_model
# Get default image model from AIModelConfig
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
provider = model_config.provider if model_config else 'openai'
model = default_model
else:
model = account_config.get('model') or account_config.get('imageModel') or global_settings.dalle_model
provider = 'openai'
model = 'dall-e-3'
# Get model-specific landscape size
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1280x768')
default_featured_size = model_landscape_size if provider == 'runware' else '1792x1024'
# Get image style with provider-specific defaults
image_style = account_config.get('image_type') or global_settings.image_style
# Get image style from SystemAISettings with AccountSettings overrides
image_style = SystemAISettings.get_effective_image_style(account)
# Style options from GlobalIntegrationSettings model - loaded dynamically
# Style options - loaded from SystemAISettings model choices
# Runware: Uses all styles with prompt enhancement
# OpenAI DALL-E: Only supports 'natural' or 'vivid'
if provider == 'openai':
# Get DALL-E styles from model definition
available_styles = [
{'value': opt[0], 'label': opt[1], 'description': opt[2]}
for opt in GlobalIntegrationSettings.DALLE_STYLE_OPTIONS
{'value': 'vivid', 'label': 'Vivid', 'description': 'Dramatic, hyper-realistic style'},
{'value': 'natural', 'label': 'Natural', 'description': 'Natural, realistic style'},
]
# Map stored style to DALL-E compatible
if image_style not in ['vivid', 'natural']:
image_style = 'natural' # Default to natural for photorealistic
else:
# Get Runware styles from model definition
available_styles = [
{'value': opt[0], 'label': opt[1], 'description': opt[2]}
for opt in GlobalIntegrationSettings.IMAGE_STYLE_OPTIONS
{'value': opt[0], 'label': opt[1]}
for opt in SystemAISettings.IMAGE_STYLE_CHOICES
]
# Default to photorealistic for Runware if not set
if not image_style or image_style in ['natural', 'vivid']:
@@ -1022,12 +923,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
'provider': provider,
'model': model,
'image_type': image_style,
'available_styles': available_styles, # Loaded from GlobalIntegrationSettings model
'max_in_article_images': account_config.get('max_in_article_images') or global_settings.max_in_article_images,
'image_format': account_config.get('image_format', 'webp'),
'desktop_enabled': account_config.get('desktop_enabled', True),
'featured_image_size': account_config.get('featured_image_size') or default_featured_size,
'desktop_image_size': account_config.get('desktop_image_size') or global_settings.desktop_image_size,
'available_styles': available_styles,
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
'image_format': 'webp',
'desktop_enabled': True,
'featured_image_size': default_featured_size,
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
}
},
request=request

View File

@@ -0,0 +1,86 @@
# Generated by Django 5.2.9 on 2026-01-04 06:11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0014_update_runware_models'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='globalintegrationsettings',
name='anthropic_model',
field=models.CharField(choices=[('claude-3-5-sonnet-20241022', 'Claude 3.5 Sonnet - $3.00 / $15.00 per 1M tokens'), ('claude-3-5-haiku-20241022', 'Claude 3.5 Haiku - $1.00 / $5.00 per 1M tokens'), ('claude-3-opus-20240229', 'Claude 3 Opus - $15.00 / $75.00 per 1M tokens'), ('claude-3-sonnet-20240229', 'Claude 3 Sonnet - $3.00 / $15.00 per 1M tokens'), ('claude-3-haiku-20240307', 'Claude 3 Haiku - $0.25 / $1.25 per 1M tokens')], default='claude-3-5-sonnet-20241022', help_text='Default Claude model (accounts can override if plan allows)', max_length=100),
),
migrations.AlterField(
model_name='globalintegrationsettings',
name='default_image_service',
field=models.CharField(choices=[('openai', 'OpenAI DALL-E'), ('runware', 'Runware')], default='openai', help_text='Default image generation service for all accounts (openai=DALL-E, runware=Runware, bria=Bria)', max_length=20),
),
migrations.AlterField(
model_name='globalintegrationsettings',
name='image_style',
field=models.CharField(choices=[('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('3d_render', '3D Render'), ('minimal_flat', 'Minimal / Flat Design'), ('artistic', 'Artistic / Painterly'), ('cartoon', 'Cartoon / Stylized')], default='photorealistic', help_text='Default image style for all providers (accounts can override if plan allows)', max_length=30),
),
migrations.AlterField(
model_name='globalmodulesettings',
name='linker_enabled',
field=models.BooleanField(default=False, help_text='Enable Linker module platform-wide (Phase 2)'),
),
migrations.AlterField(
model_name='globalmodulesettings',
name='optimizer_enabled',
field=models.BooleanField(default=False, help_text='Enable Optimizer module platform-wide (Phase 2)'),
),
migrations.AlterField(
model_name='globalmodulesettings',
name='site_builder_enabled',
field=models.BooleanField(default=False, help_text='Enable Site Builder module platform-wide (DEPRECATED)'),
),
migrations.AlterField(
model_name='moduleenablesettings',
name='linker_enabled',
field=models.BooleanField(default=False, help_text='Enable Linker module (Phase 2)'),
),
migrations.AlterField(
model_name='moduleenablesettings',
name='optimizer_enabled',
field=models.BooleanField(default=False, help_text='Enable Optimizer module (Phase 2)'),
),
migrations.AlterField(
model_name='moduleenablesettings',
name='site_builder_enabled',
field=models.BooleanField(default=False, help_text='Enable Site Builder module (DEPRECATED)'),
),
migrations.CreateModel(
name='IntegrationProvider',
fields=[
('provider_id', models.CharField(help_text="Unique identifier (e.g., 'openai', 'stripe', 'resend')", max_length=50, primary_key=True, serialize=False, unique=True)),
('display_name', models.CharField(help_text='Human-readable name', max_length=100)),
('provider_type', models.CharField(choices=[('ai', 'AI Provider'), ('email', 'Email Service'), ('payment', 'Payment Gateway'), ('storage', 'Storage Service'), ('analytics', 'Analytics'), ('other', 'Other')], db_index=True, default='ai', max_length=20)),
('api_key', models.CharField(blank=True, help_text='Primary API key or token', max_length=500)),
('api_secret', models.CharField(blank=True, help_text='Secondary secret (for OAuth, Stripe secret key, etc.)', max_length=500)),
('webhook_secret', models.CharField(blank=True, help_text='Webhook signing secret (Stripe, PayPal)', max_length=500)),
('api_endpoint', models.URLField(blank=True, help_text='Custom API endpoint (if not default)')),
('webhook_url', models.URLField(blank=True, help_text='Webhook URL configured at provider')),
('config', models.JSONField(blank=True, default=dict, help_text='Provider-specific config: rate limits, regions, modes, etc.')),
('is_active', models.BooleanField(db_index=True, default=True)),
('is_sandbox', models.BooleanField(default=False, help_text='True if using sandbox/test mode (Stripe test keys, PayPal sandbox)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_provider_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Integration Provider',
'verbose_name_plural': 'Integration Providers',
'db_table': 'igny8_integration_providers',
'ordering': ['provider_type', 'display_name'],
},
),
]

View File

@@ -0,0 +1,136 @@
# Generated manually for data migration
from django.db import migrations
def populate_integration_providers(apps, schema_editor):
"""
Populate IntegrationProvider with all 3rd party integrations.
API keys will need to be configured in Django admin after migration.
"""
IntegrationProvider = apps.get_model('system', 'IntegrationProvider')
providers = [
# AI Providers
{
'provider_id': 'openai',
'display_name': 'OpenAI',
'provider_type': 'ai',
'api_key': '', # To be configured in admin
'config': {
'default_model': 'gpt-5.1',
'models': ['gpt-4o-mini', 'gpt-4o', 'gpt-5.1', 'dall-e-3'],
},
'is_active': True,
},
{
'provider_id': 'runware',
'display_name': 'Runware',
'provider_type': 'ai',
'api_key': '', # To be configured in admin
'config': {
'default_model': 'runware:97@1',
'models': ['runware:97@1', 'google:4@2'],
},
'is_active': True,
},
{
'provider_id': 'anthropic',
'display_name': 'Anthropic (Claude)',
'provider_type': 'ai',
'api_key': '',
'config': {
'default_model': 'claude-3-5-sonnet-20241022',
},
'is_active': False, # Not currently used
},
{
'provider_id': 'google',
'display_name': 'Google Cloud',
'provider_type': 'ai',
'api_key': '',
'config': {},
'is_active': False, # Future: Gemini
},
# Payment Providers
{
'provider_id': 'stripe',
'display_name': 'Stripe',
'provider_type': 'payment',
'api_key': '', # Public key
'api_secret': '', # Secret key
'webhook_secret': '',
'config': {
'currency': 'usd',
},
'is_active': True,
'is_sandbox': True, # Start in test mode
},
{
'provider_id': 'paypal',
'display_name': 'PayPal',
'provider_type': 'payment',
'api_key': '', # Client ID
'api_secret': '', # Client Secret
'webhook_secret': '',
'api_endpoint': 'https://api-m.sandbox.paypal.com', # Sandbox endpoint
'config': {
'currency': 'usd',
},
'is_active': True,
'is_sandbox': True,
},
# Email Providers
{
'provider_id': 'resend',
'display_name': 'Resend',
'provider_type': 'email',
'api_key': '',
'config': {
'from_email': 'noreply@igny8.com',
'from_name': 'IGNY8',
},
'is_active': True,
},
# Storage Providers (Future)
{
'provider_id': 'cloudflare_r2',
'display_name': 'Cloudflare R2',
'provider_type': 'storage',
'api_key': '', # Access Key ID
'api_secret': '', # Secret Access Key
'config': {
'bucket': '',
'endpoint': '',
},
'is_active': False,
},
]
for provider_data in providers:
IntegrationProvider.objects.update_or_create(
provider_id=provider_data['provider_id'],
defaults=provider_data
)
def reverse_migration(apps, schema_editor):
"""Remove seeded providers"""
IntegrationProvider = apps.get_model('system', 'IntegrationProvider')
IntegrationProvider.objects.filter(
provider_id__in=['openai', 'runware', 'anthropic', 'google', 'stripe', 'paypal', 'resend', 'cloudflare_r2']
).delete()
class Migration(migrations.Migration):
dependencies = [
('system', '0015_add_integration_provider'),
]
operations = [
migrations.RunPython(populate_integration_providers, reverse_migration),
]

View File

@@ -0,0 +1,15 @@
# Generated by Django 5.2.9 on 2026-01-04 08:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('system', '0016_populate_integration_providers'),
]
operations = [
# AccountIntegrationOverride was already deleted in a previous migration
# Keeping this migration empty for now
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.9 on 2026-01-04 08:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0017_create_ai_settings'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemAISettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('temperature', models.FloatField(default=0.7, help_text='AI temperature (0.0-2.0). Higher = more creative.')),
('max_tokens', models.IntegerField(default=8192, help_text='Max response tokens')),
('image_style', models.CharField(choices=[('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('3d_render', '3D Render'), ('minimal_flat', 'Minimal / Flat Design'), ('artistic', 'Artistic / Painterly'), ('cartoon', 'Cartoon / Stylized')], default='photorealistic', help_text='Default image style', max_length=30)),
('image_quality', models.CharField(choices=[('standard', 'Standard'), ('hd', 'HD')], default='standard', help_text='Default image quality (standard/hd)', max_length=20)),
('max_images_per_article', models.IntegerField(default=4, help_text='Max in-article images (1-8)')),
('image_size', models.CharField(choices=[('1024x1024', '1024x1024 (Square)'), ('1792x1024', '1792x1024 (Landscape)'), ('1024x1792', '1024x1792 (Portrait)')], default='1024x1024', help_text='Default image dimensions', max_length=20)),
('updated_at', models.DateTimeField(auto_now=True)),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='system_ai_settings_updates', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'System AI Settings',
'verbose_name_plural': 'System AI Settings',
'db_table': 'igny8_system_ai_settings',
},
),
]

View File

@@ -0,0 +1,91 @@
# Generated by Django 5.2.9 on 2026-01-04 10:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0018_create_ai_settings_table'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='accountsettings',
name='config',
),
migrations.RemoveField(
model_name='accountsettings',
name='is_active',
),
migrations.RemoveField(
model_name='integrationprovider',
name='webhook_url',
),
migrations.RemoveField(
model_name='modulesettings',
name='config',
),
migrations.AddField(
model_name='accountsettings',
name='value',
field=models.JSONField(default=dict, help_text='Setting value'),
),
migrations.AlterField(
model_name='accountsettings',
name='key',
field=models.CharField(db_index=True, help_text='Setting key', max_length=100),
),
migrations.AlterField(
model_name='integrationprovider',
name='api_endpoint',
field=models.URLField(blank=True, help_text='Custom endpoint (optional)'),
),
migrations.AlterField(
model_name='integrationprovider',
name='api_key',
field=models.CharField(blank=True, help_text='Primary API key', max_length=500),
),
migrations.AlterField(
model_name='integrationprovider',
name='api_secret',
field=models.CharField(blank=True, help_text='Secondary secret (Stripe, PayPal)', max_length=500),
),
migrations.AlterField(
model_name='integrationprovider',
name='config',
field=models.JSONField(blank=True, default=dict, help_text='Provider-specific config'),
),
migrations.AlterField(
model_name='integrationprovider',
name='is_active',
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable provider'),
),
migrations.AlterField(
model_name='integrationprovider',
name='is_sandbox',
field=models.BooleanField(default=False, help_text='Test mode flag'),
),
migrations.AlterField(
model_name='integrationprovider',
name='provider_type',
field=models.CharField(choices=[('ai', 'AI Provider'), ('payment', 'Payment Gateway'), ('email', 'Email Service'), ('storage', 'Storage Service')], db_index=True, default='ai', help_text='ai / payment / email / storage', max_length=20),
),
migrations.AlterField(
model_name='integrationprovider',
name='updated_by',
field=models.ForeignKey(blank=True, help_text='Audit trail', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_provider_updates', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='integrationprovider',
name='webhook_secret',
field=models.CharField(blank=True, help_text='Webhook signing secret', max_length=500),
),
# AccountIntegrationOverride table doesn't exist in DB, so skip delete
# migrations.DeleteModel(
# name='AccountIntegrationOverride',
# ),
]

View File

@@ -2,6 +2,7 @@
System module models - for global settings and prompts
"""
from django.db import models
from django.conf import settings
from igny8_core.auth.models import AccountBaseModel
# Import settings models
@@ -10,6 +11,141 @@ from .settings_models import (
)
class IntegrationProvider(models.Model):
"""
Centralized storage for ALL external service API keys.
Per final-model-schemas.md:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| provider_id | CharField(50) PK | Yes | openai, runware, stripe, paypal, resend |
| display_name | CharField(100) | Yes | Human-readable name |
| provider_type | CharField(20) | Yes | ai / payment / email / storage |
| api_key | CharField(500) | No | Primary API key |
| api_secret | CharField(500) | No | Secondary secret (Stripe, PayPal) |
| webhook_secret | CharField(500) | No | Webhook signing secret |
| api_endpoint | URLField | No | Custom endpoint (optional) |
| config | JSONField | No | Provider-specific config |
| is_active | BooleanField | Yes | Enable/disable provider |
| is_sandbox | BooleanField | Yes | Test mode flag |
| updated_by | FK(User) | No | Audit trail |
| created_at | DateTime | Auto | |
| updated_at | DateTime | Auto | |
"""
PROVIDER_TYPE_CHOICES = [
('ai', 'AI Provider'),
('payment', 'Payment Gateway'),
('email', 'Email Service'),
('storage', 'Storage Service'),
]
# Primary Key
provider_id = models.CharField(
max_length=50,
unique=True,
primary_key=True,
help_text="Unique identifier (e.g., 'openai', 'stripe', 'resend')"
)
# Display name
display_name = models.CharField(
max_length=100,
help_text="Human-readable name"
)
# Provider type
provider_type = models.CharField(
max_length=20,
choices=PROVIDER_TYPE_CHOICES,
default='ai',
db_index=True,
help_text="ai / payment / email / storage"
)
# Authentication
api_key = models.CharField(
max_length=500,
blank=True,
help_text="Primary API key"
)
api_secret = models.CharField(
max_length=500,
blank=True,
help_text="Secondary secret (Stripe, PayPal)"
)
webhook_secret = models.CharField(
max_length=500,
blank=True,
help_text="Webhook signing secret"
)
# Endpoints
api_endpoint = models.URLField(
blank=True,
help_text="Custom endpoint (optional)"
)
# Configuration
config = models.JSONField(
default=dict,
blank=True,
help_text="Provider-specific config"
)
# Status
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Enable/disable provider"
)
is_sandbox = models.BooleanField(
default=False,
help_text="Test mode flag"
)
# Audit
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='integration_provider_updates',
help_text="Audit trail"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_integration_providers'
verbose_name = 'Integration Provider'
verbose_name_plural = 'Integration Providers'
ordering = ['provider_type', 'display_name']
def __str__(self):
status = "Active" if self.is_active else "Inactive"
mode = "(Sandbox)" if self.is_sandbox else ""
return f"{self.display_name} - {status} {mode}".strip()
@classmethod
def get_provider(cls, provider_id: str):
"""Get provider by ID, returns None if not found or inactive"""
try:
return cls.objects.get(provider_id=provider_id, is_active=True)
except cls.DoesNotExist:
return None
@classmethod
def get_api_key(cls, provider_id: str) -> str:
"""Get API key for a provider"""
provider = cls.get_provider(provider_id)
return provider.api_key if provider else ""
@classmethod
def get_providers_by_type(cls, provider_type: str):
"""Get all active providers of a type"""
return cls.objects.filter(provider_type=provider_type, is_active=True)
class AIPrompt(AccountBaseModel):
"""
Account-specific AI Prompt templates.

View File

@@ -17,11 +17,29 @@ class SystemSettingsAdmin(ModelAdmin):
@admin.register(AccountSettings)
class AccountSettingsAdmin(AccountAdminMixin, ModelAdmin):
list_display = ['account', 'key', 'is_active', 'updated_at']
list_filter = ['is_active', 'account']
"""
AccountSettings - Generic key-value store for account-specific settings.
Per final-model-schemas.md
"""
list_display = ['account', 'key', 'updated_at']
list_filter = ['account']
search_fields = ['key', 'account__name']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Account & Key', {
'fields': ('account', 'key')
}),
('Value', {
'fields': ('value',),
'description': 'JSON value for this setting'
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def get_account_display(self, obj):
"""Safely get account name"""
try:

View File

@@ -7,7 +7,6 @@ from igny8_core.auth.models import AccountBaseModel
class BaseSettings(AccountBaseModel):
"""Base class for all account-scoped settings models"""
config = models.JSONField(default=dict, help_text="Settings configuration as JSON")
is_active = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -35,9 +34,39 @@ class SystemSettings(models.Model):
return f"SystemSetting: {self.key}"
class AccountSettings(BaseSettings):
"""Account-level settings"""
key = models.CharField(max_length=255, db_index=True, help_text="Settings key identifier")
class AccountSettings(AccountBaseModel):
"""
Generic key-value store for account-specific settings.
Per final-model-schemas.md:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| id | AutoField PK | Auto | |
| account | FK(Account) | Yes | |
| key | CharField(100) | Yes | Setting key |
| value | JSONField | Yes | Setting value |
| created_at | DateTime | Auto | |
| updated_at | DateTime | Auto | |
AI-Related Keys (override AISettings defaults):
- ai.temperature
- ai.max_tokens
- ai.image_style
- ai.image_quality
- ai.max_images
- ai.image_quality_tier
"""
key = models.CharField(
max_length=100,
db_index=True,
help_text="Setting key"
)
value = models.JSONField(
default=dict,
help_text="Setting value"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_account_settings'

View File

@@ -3,6 +3,7 @@ Serializers for Settings Models
"""
from rest_framework import serializers
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
from .ai_settings import SystemAISettings
from .validators import validate_settings_schema
@@ -71,3 +72,21 @@ class AISettingsSerializer(serializers.ModelSerializer):
]
read_only_fields = ['created_at', 'updated_at', 'account']
class SystemAISettingsSerializer(serializers.Serializer):
"""
Serializer for SystemAISettings (singleton) with AccountSettings overrides.
Per the plan: GET/PUT /api/v1/accounts/settings/ai/
"""
# Content Generation
temperature = serializers.FloatField(min_value=0.0, max_value=2.0)
max_tokens = serializers.IntegerField(min_value=100, max_value=32000)
# Image Generation
image_quality_tier = serializers.CharField(max_length=20)
image_style = serializers.CharField(max_length=30)
max_images = serializers.IntegerField(min_value=1, max_value=8)
# Read-only metadata
quality_tiers = serializers.ListField(read_only=True)
styles = serializers.ListField(read_only=True)

View File

@@ -15,10 +15,14 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
from .global_settings_models import GlobalModuleSettings
from .ai_settings import SystemAISettings
from .settings_serializers import (
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
ModuleSettingsSerializer, AISettingsSerializer
ModuleSettingsSerializer, AISettingsSerializer, SystemAISettingsSerializer
)
import logging
logger = logging.getLogger(__name__)
@extend_schema_view(
@@ -510,3 +514,184 @@ class AISettingsViewSet(AccountModelViewSet):
serializer.save(account=account)
@extend_schema_view(
list=extend_schema(tags=['AI Settings']),
)
class ContentGenerationSettingsViewSet(viewsets.ViewSet):
"""
ViewSet for Content Generation Settings per the plan.
GET /api/v1/accounts/settings/ai/ - Get merged SystemAISettings + AccountSettings
PUT /api/v1/accounts/settings/ai/ - Save account overrides to AccountSettings
This endpoint returns:
- content_generation: temperature, max_tokens
- image_generation: quality_tiers, selected_tier, styles, selected_style, max_images
"""
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
authentication_classes = [JWTAuthentication]
throttle_scope = 'system'
throttle_classes = [DebugScopedRateThrottle]
def _get_account(self, request):
"""Get account from request"""
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
return account
def list(self, request):
"""
GET /api/v1/accounts/settings/ai/
Returns merged AI settings (SystemAISettings + AccountSettings overrides)
Response structure per the plan:
{
"content_generation": { "temperature": 0.7, "max_tokens": 8192 },
"image_generation": {
"quality_tiers": [...],
"selected_tier": "quality",
"styles": [...],
"selected_style": "photorealistic",
"max_images": 4,
"max_allowed": 8
}
}
"""
account = self._get_account(request)
try:
from igny8_core.business.billing.models import AIModelConfig
# Get quality tiers from AIModelConfig (image models)
quality_tiers = []
for model in AIModelConfig.objects.filter(model_type='image', is_active=True).order_by('credits_per_image'):
tier = model.quality_tier or 'basic'
# Avoid duplicates
if not any(t['tier'] == tier for t in quality_tiers):
quality_tiers.append({
'tier': tier,
'credits': model.credits_per_image or 1,
'label': tier.title(),
'description': f"{model.display_name} quality",
'model': model.model_name,
})
# Ensure we have at least basic tiers
if not quality_tiers:
quality_tiers = [
{'tier': 'basic', 'credits': 1, 'label': 'Basic', 'description': 'Fast, simple images'},
{'tier': 'quality', 'credits': 5, 'label': 'Quality', 'description': 'Balanced quality'},
{'tier': 'premium', 'credits': 15, 'label': 'Premium', 'description': 'Best quality'},
]
# Get styles from SystemAISettings model choices
styles = [
{'value': opt[0], 'label': opt[1]}
for opt in SystemAISettings.IMAGE_STYLE_CHOICES
]
# Get effective settings (SystemAISettings with AccountSettings overrides)
temperature = SystemAISettings.get_effective_temperature(account)
max_tokens = SystemAISettings.get_effective_max_tokens(account)
image_style = SystemAISettings.get_effective_image_style(account)
max_images = SystemAISettings.get_effective_max_images(account)
# Get selected quality tier from AccountSettings
selected_tier = 'quality' # Default
if account:
tier_setting = AccountSettings.objects.filter(
account=account,
key='ai.image_quality_tier'
).first()
if tier_setting and tier_setting.config:
selected_tier = tier_setting.config.get('value', 'quality')
response_data = {
'content_generation': {
'temperature': temperature,
'max_tokens': max_tokens,
},
'image_generation': {
'quality_tiers': quality_tiers,
'selected_tier': selected_tier,
'styles': styles,
'selected_style': image_style,
'max_images': max_images,
'max_allowed': 8,
}
}
return success_response(data=response_data, request=request)
except Exception as e:
logger.error(f"Error getting AI settings: {e}", exc_info=True)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
def create(self, request):
"""
PUT/POST /api/v1/accounts/settings/ai/
Save account-specific overrides to AccountSettings.
Request body per the plan:
{
"temperature": 0.8,
"max_tokens": 4096,
"image_quality_tier": "premium",
"image_style": "illustration",
"max_images": 6
}
"""
account = self._get_account(request)
if not account:
return error_response(
error='Account not found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
try:
data = request.data
saved_keys = []
# Map request fields to AccountSettings keys
key_mappings = {
'temperature': 'ai.temperature',
'max_tokens': 'ai.max_tokens',
'image_quality_tier': 'ai.image_quality_tier',
'image_style': 'ai.image_style',
'max_images': 'ai.max_images',
}
for field, account_key in key_mappings.items():
if field in data:
AccountSettings.objects.update_or_create(
account=account,
key=account_key,
defaults={'config': {'value': data[field]}}
)
saved_keys.append(account_key)
logger.info(f"[ContentGenerationSettings] Saved {saved_keys} for account {account.id}")
return success_response(
data={'saved_keys': saved_keys},
message='AI settings saved successfully',
request=request
)
except Exception as e:
logger.error(f"Error saving AI settings: {e}", exc_info=True)
return error_response(
error=str(e),
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)

View File

@@ -790,7 +790,8 @@ UNFOLD = {
"icon": "settings",
"collapsible": True,
"items": [
{"title": "Integration Settings", "icon": "integration_instructions", "link": lambda request: "/admin/system/globalintegrationsettings/"},
{"title": "Integration Providers", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/"},
{"title": "System AI Settings", "icon": "psychology", "link": lambda request: "/admin/system/systemaisettings/"},
{"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/globalmodulesettings/"},
{"title": "AI Prompts", "icon": "smart_toy", "link": lambda request: "/admin/system/globalaiprompt/"},
{"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"},

View File

@@ -44,73 +44,59 @@ class AIProcessor:
def __init__(self, account=None):
"""
Initialize AIProcessor. Can optionally load API keys and model from IntegrationSettings for account.
Initialize AIProcessor.
API keys come from IntegrationProvider (centralized, platform-wide).
Model comes from AIModelConfig (is_default=True).
AI parameters come from SystemAISettings with AccountSettings overrides.
Args:
account: Optional account object to load API keys and model from IntegrationSettings
account: Optional account object for per-account setting overrides
"""
self.account = account
# Get API keys from IntegrationSettings if account provided, else fallback to Django settings
self.openai_api_key = self._get_api_key('openai', self.account)
self.runware_api_key = self._get_api_key('runware', self.account)
# Get model from IntegrationSettings if account provided, else fallback to Django settings
self.default_model = self._get_model('openai', self.account)
# Get API keys from IntegrationProvider (centralized)
self.openai_api_key = self._get_api_key('openai')
self.runware_api_key = self._get_api_key('runware')
# Get model from AIModelConfig (is_default=True)
self.default_model = self._get_model('openai')
# Use global model rates
self.model_rates = MODEL_RATES
self.image_model_rates = IMAGE_MODEL_RATES
def _get_api_key(self, integration_type: str, account=None) -> Optional[str]:
"""Get API key from IntegrationSettings or Django settings"""
if account:
def _get_api_key(self, integration_type: str) -> Optional[str]:
"""Get API key from IntegrationProvider (centralized)"""
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:
api_key = settings_obj.config.get('apiKey')
from igny8_core.ai.model_registry import ModelRegistry
api_key = ModelRegistry.get_api_key(integration_type)
if api_key:
logger.info(f"Loaded {integration_type} API key from IntegrationSettings for account {account.id}")
logger.debug(f"Loaded {integration_type} API key from IntegrationProvider")
return api_key
except Exception as e:
logger.warning(f"Could not load {integration_type} API key from IntegrationSettings: {e}", exc_info=True)
logger.warning(f"Could not load {integration_type} API key from IntegrationProvider: {e}")
# Fallback to Django settings
# Fallback to Django settings (for backward compatibility)
if integration_type == 'openai':
return getattr(settings, 'OPENAI_API_KEY', None)
elif integration_type == 'runware':
return getattr(settings, 'RUNWARE_API_KEY', None)
return None
def _get_model(self, integration_type: str, account=None) -> str:
"""Get model from IntegrationSettings or Django settings"""
if account and integration_type == 'openai':
def _get_model(self, integration_type: str) -> str:
"""Get default model from AIModelConfig (is_default=True)"""
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:
model = settings_obj.config.get('model')
if model:
# Validate model is in our supported list
if model in MODEL_RATES:
logger.info(f"Using model '{model}' from IntegrationSettings for account {account.id}")
return model
else:
logger.warning(f"Model '{model}' from IntegrationSettings not in supported models, using default")
from igny8_core.ai.model_registry import ModelRegistry
default_model = ModelRegistry.get_default_model('text')
if default_model:
logger.debug(f"Using model '{default_model}' from AIModelConfig")
return default_model
except Exception as e:
logger.warning(f"Could not load {integration_type} model from IntegrationSettings: {e}", exc_info=True)
logger.warning(f"Could not load default model from AIModelConfig: {e}")
# Fallback to Django settings or default
default_model = getattr(settings, 'DEFAULT_AI_MODEL', 'gpt-4.1')
logger.info(f"Using default model '{default_model}' (no IntegrationSettings found)")
logger.info(f"Using fallback model '{default_model}'")
return default_model
def _call_openai(

View File

@@ -202,9 +202,53 @@ Django Admin
---
## Next Steps (From Original Plan)
## Phase 2: AI Models & Credits Refactor - COMPLETED
### IntegrationProvider Model Created
- New model: `IntegrationProvider` in `modules/system/models.py`
- Centralized storage for ALL external service API keys
- Supports: AI providers, payment gateways, email services, storage
- Migrated OpenAI and Runware API keys from GlobalIntegrationSettings
- Admin interface added in `modules/system/admin.py`
- Added to admin sidebar under "Global Settings"
### AIModelConfig Enhanced
- Added `tokens_per_credit` - for text models (e.g., 1000 tokens = 1 credit)
- Added `credits_per_image` - for image models (e.g., 1, 5, 15 credits)
- Added `quality_tier` - for frontend UI (basic/quality/premium)
- Migration `0025_add_aimodel_credit_fields` adds fields
- Migration `0026_populate_aimodel_credits` sets initial values
### ModelRegistry Updated
- Removed fallback to `constants.py` - database is now authoritative
- Added `get_provider()`, `get_api_key()`, `get_api_secret()`, `get_webhook_secret()`
- Provider caching with TTL
### CreditService Updated
- Added `calculate_credits_for_image(model_name, num_images)` - uses AIModelConfig.credits_per_image
- Added `calculate_credits_from_tokens_by_model(model_name, total_tokens)` - uses AIModelConfig.tokens_per_credit
- Added `deduct_credits_for_image()` - convenience method
### Files Changed (Phase 2)
| File | Change |
|------|--------|
| `modules/system/models.py` | Added IntegrationProvider model |
| `modules/system/admin.py` | Added IntegrationProviderAdmin |
| `business/billing/models.py` | Added tokens_per_credit, credits_per_image, quality_tier to AIModelConfig |
| `business/billing/services/credit_service.py` | Added image/model-based credit calculation |
| `ai/model_registry.py` | Removed constants fallback, added provider methods |
| `ai/ai_core.py` | Use ModelRegistry for API keys, removed constants fallback |
| `ai/constants.py` | Marked MODEL_RATES, IMAGE_MODEL_RATES as DEPRECATED |
| `ai/settings.py` | Use ModelRegistry for model validation |
| `ai/validators.py` | Removed constants fallback |
| `modules/system/integration_views.py` | Use ModelRegistry for cost calculation |
| `modules/billing/serializers.py` | Added new fields to AIModelConfigSerializer |
---
## Next Steps
1.**Django Admin Cleanup** - DONE
2.**Simplify AI Settings** - Merge content + image settings into AccountSettings
3. **Create IntegrationProvider** - Move API keys to dedicated model
4. **AIModelConfig Enhancement** - Add tokens_per_credit, credits_per_image, quality_tier
3. **Create IntegrationProvider** - DONE (API keys now in dedicated model)
4. **AIModelConfig Enhancement** - DONE (tokens_per_credit, credits_per_image, quality_tier added)

View File

@@ -0,0 +1,314 @@
# Final Model Schemas - Clean State
## Overview
This document defines the simplified, clean architecture for AI configuration and billing models.
**Total Models**: 5 (down from 7)
- IntegrationProvider (API keys)
- AIModelConfig (model definitions + pricing)
- AISettings (system defaults + account overrides) - *renamed from GlobalIntegrationSettings*
- AccountSettings (generic key-value store)
- CreditCostConfig (operation-level pricing)
**Models Removed**:
- IntegrationSettings (merged into AISettings/AccountSettings)
- CreditCostConfig.tokens_per_credit (moved to AIModelConfig)
---
## 1. IntegrationProvider (API Keys Only)
Centralized storage for ALL external service API keys.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `provider_id` | CharField(50) PK | Yes | openai, runware, stripe, paypal, resend |
| `display_name` | CharField(100) | Yes | Human-readable name |
| `provider_type` | CharField(20) | Yes | ai / payment / email / storage |
| `api_key` | CharField(500) | No | Primary API key |
| `api_secret` | CharField(500) | No | Secondary secret (Stripe, PayPal) |
| `webhook_secret` | CharField(500) | No | Webhook signing secret |
| `api_endpoint` | URLField | No | Custom endpoint (optional) |
| `config` | JSONField | No | Provider-specific config |
| `is_active` | BooleanField | Yes | Enable/disable provider |
| `is_sandbox` | BooleanField | Yes | Test mode flag |
| `updated_by` | FK(User) | No | Audit trail |
| `created_at` | DateTime | Auto | |
| `updated_at` | DateTime | Auto | |
**Seeded Providers**:
- `openai` - AI (text + DALL-E)
- `runware` - AI (images)
- `anthropic` - AI (future)
- `stripe` - Payment
- `paypal` - Payment
- `resend` - Email
---
## 2. AIModelConfig (Single Source of Truth for Models)
All AI models (text + image) with pricing and credit configuration.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | AutoField PK | Auto | |
| `model_name` | CharField(100) | Yes | gpt-5.1, dall-e-3, runware:97@1 |
| `model_type` | CharField(20) | Yes | text / image |
| `provider` | CharField(50) | Yes | Links to IntegrationProvider |
| `display_name` | CharField(200) | Yes | Human-readable |
| `is_default` | BooleanField | Yes | One default per type |
| `is_active` | BooleanField | Yes | Enable/disable |
| `cost_per_1k_input` | DecimalField | No | Provider cost (USD) - text models |
| `cost_per_1k_output` | DecimalField | No | Provider cost (USD) - text models |
| `tokens_per_credit` | IntegerField | No | Text: tokens per 1 credit (e.g., 1000) |
| `credits_per_image` | IntegerField | No | Image: credits per image (e.g., 1, 5, 15) |
| `quality_tier` | CharField(20) | No | basic / quality / premium |
| `max_tokens` | IntegerField | No | Model token limit |
| `context_window` | IntegerField | No | Model context size |
| `capabilities` | JSONField | No | vision, function_calling, etc. |
| `created_at` | DateTime | Auto | |
| `updated_at` | DateTime | Auto | |
**Credit Configuration Examples**:
| Model | Type | tokens_per_credit | credits_per_image | quality_tier |
|-------|------|-------------------|-------------------|--------------|
| gpt-5.1 | text | 1000 | - | - |
| gpt-4o-mini | text | 10000 | - | - |
| runware:97@1 | image | - | 1 | basic |
| dall-e-3 | image | - | 5 | quality |
| google:4@2 | image | - | 15 | premium |
---
## 3. AISettings (Renamed from GlobalIntegrationSettings)
System-wide AI defaults. Singleton (pk=1).
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | AutoField PK | Auto | 1 | Singleton |
| `temperature` | FloatField | Yes | 0.7 | AI temperature (0.0-2.0) |
| `max_tokens` | IntegerField | Yes | 8192 | Max response tokens |
| `image_style` | CharField(30) | Yes | photorealistic | Default image style |
| `image_quality` | CharField(20) | Yes | standard | standard / hd |
| `max_images_per_article` | IntegerField | Yes | 4 | Max in-article images |
| `image_size` | CharField(20) | Yes | 1024x1024 | Default image dimensions |
| `updated_by` | FK(User) | No | | Audit trail |
| `updated_at` | DateTime | Auto | | |
**Removed Fields** (now elsewhere):
- All `*_api_key` fields → IntegrationProvider
- All `*_model` fields → AIModelConfig.is_default
- `default_text_provider` → AIModelConfig.is_default where model_type='text'
- `default_image_service` → AIModelConfig.is_default where model_type='image'
**Image Style Choices**:
- `photorealistic` - Ultra realistic photography
- `illustration` - Digital illustration
- `3d_render` - Computer generated 3D
- `minimal_flat` - Minimal / Flat Design
- `artistic` - Artistic / Painterly
- `cartoon` - Cartoon / Stylized
---
## 4. AccountSettings (Per-Account Overrides)
Generic key-value store for account-specific settings.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | AutoField PK | Auto | |
| `account` | FK(Account) | Yes | |
| `key` | CharField(100) | Yes | Setting key |
| `value` | JSONField | Yes | Setting value |
| `created_at` | DateTime | Auto | |
| `updated_at` | DateTime | Auto | |
**Unique Constraint**: `(account, key)`
**AI-Related Keys** (override AISettings defaults):
| Key | Type | Example | Notes |
|-----|------|---------|-------|
| `ai.temperature` | float | 0.7 | Override system default |
| `ai.max_tokens` | int | 8192 | Override system default |
| `ai.image_style` | string | "photorealistic" | Override system default |
| `ai.image_quality` | string | "hd" | Override system default |
| `ai.max_images` | int | 6 | Override system default |
| `ai.image_quality_tier` | string | "quality" | User's preferred tier |
---
## 5. CreditCostConfig (Operation-Level Pricing)
Fixed credit costs per operation type.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `operation_type` | CharField(50) PK | Yes | Unique operation ID |
| `display_name` | CharField(100) | Yes | Human-readable |
| `base_credits` | IntegerField | Yes | Fixed credits per operation |
| `is_active` | BooleanField | Yes | Enable/disable |
| `description` | TextField | No | Admin notes |
**Removed**: `tokens_per_credit` (now in AIModelConfig)
---
## Frontend Settings Structure
### Content Generation Settings Tab
```
┌─────────────────────────────────────────────────────────────┐
│ Content Generation Settings │
├─────────────────────────────────────────────────────────────┤
│ │
│ AI Parameters │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Temperature [=====○====] 0.7 │ │
│ │ More focused ←→ More creative │ │
│ │ │ │
│ │ Max Tokens [8192 ▼] │ │
│ │ Response length limit │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Image Generation │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Image Quality │ │
│ │ ○ Basic (1 credit/image) - Fast, simple │ │
│ │ ● Quality (5 credits/image) - Balanced │ │
│ │ ○ Premium (15 credits/image) - Best quality │ │
│ │ │ │
│ │ Image Style [Photorealistic ▼] │ │
│ │ │ │
│ │ Images per Article [4 ▼] (max 8) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ [Save Settings] │
└─────────────────────────────────────────────────────────────┘
```
### Frontend API Response
```
GET /api/v1/accounts/settings/ai/
Response:
{
"content_generation": {
"temperature": 0.7,
"max_tokens": 8192
},
"image_generation": {
"quality_tiers": [
{"tier": "basic", "credits": 1, "label": "Basic", "description": "Fast, simple images"},
{"tier": "quality", "credits": 5, "label": "Quality", "description": "Balanced quality"},
{"tier": "premium", "credits": 15, "label": "Premium", "description": "Best quality"}
],
"selected_tier": "quality",
"styles": [
{"value": "photorealistic", "label": "Photorealistic"},
{"value": "illustration", "label": "Illustration"},
{"value": "3d_render", "label": "3D Render"},
{"value": "minimal_flat", "label": "Minimal / Flat"},
{"value": "artistic", "label": "Artistic"},
{"value": "cartoon", "label": "Cartoon"}
],
"selected_style": "photorealistic",
"max_images": 4,
"max_allowed": 8
}
}
```
### Save Settings Request
```
PUT /api/v1/accounts/settings/ai/
Request:
{
"temperature": 0.8,
"max_tokens": 4096,
"image_quality_tier": "premium",
"image_style": "illustration",
"max_images": 6
}
```
---
## Data Flow
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ IntegrationProv. │ │ AIModelConfig │ │ AISettings │
│ │ │ │ │ (Singleton) │
│ - API keys │◄────│ - Model list │ │ │
│ - Provider info │ │ - Pricing │ │ - Defaults │
└──────────────────┘ │ - Credits config │ │ - temperature │
│ - quality_tier │ │ - max_tokens │
└────────┬─────────┘ │ - image_style │
│ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ AccountSettings │
│ │
│ Account-specific overrides: │
│ - ai.temperature = 0.8 │
│ - ai.image_quality_tier = "premium" │
│ - ai.image_style = "illustration" │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Frontend Settings UI │
│ │
│ GET /api/v1/accounts/settings/ai/ │
│ - Merges AISettings + AccountSettings │
│ - Returns effective values for account │
└──────────────────────────────────────────┘
```
---
## Migration Summary
| From | To | Action |
|------|----|--------|
| GlobalIntegrationSettings.*_api_key | IntegrationProvider | Already done |
| GlobalIntegrationSettings.*_model | AIModelConfig.is_default | Already done |
| GlobalIntegrationSettings (remaining) | AISettings (rename) | Phase 3 |
| IntegrationSettings | AccountSettings | Phase 3 - delete model |
| CreditCostConfig.tokens_per_credit | AIModelConfig.tokens_per_credit | Already done |
---
## Phase 3 Implementation Steps
1. **Rename Model**: GlobalIntegrationSettings → AISettings
2. **Remove Fields**: All `*_api_key`, `*_model` fields from AISettings
3. **Create API Endpoint**: `/api/v1/accounts/settings/ai/`
4. **Update Frontend**: Load from new endpoint, use quality_tier picker
5. **Delete Model**: IntegrationSettings (after migrating any data)
6. **Cleanup**: Remove deprecated choices/constants
---
## Files to Modify (Phase 3)
| File | Change |
|------|--------|
| `modules/system/global_settings_models.py` | Rename class, remove deprecated fields |
| `modules/system/admin.py` | Update admin for AISettings |
| `modules/system/views.py` | New AI settings API endpoint |
| `modules/system/serializers.py` | AISettingsSerializer |
| `settings.py` | Update admin sidebar |
| `ai/ai_core.py` | Use AISettings instead of GlobalIntegrationSettings |
| Frontend | New settings component with quality tier picker |

View File

@@ -88,7 +88,6 @@ const Users = lazy(() => import("./pages/Settings/Users"));
const Subscriptions = lazy(() => import("./pages/Settings/Subscriptions"));
const SystemSettings = lazy(() => import("./pages/Settings/System"));
const AccountSettings = lazy(() => import("./pages/Settings/Account"));
const AISettings = lazy(() => import("./pages/Settings/AI"));
const Plans = lazy(() => import("./pages/Settings/Plans"));
const Industries = lazy(() => import("./pages/Settings/Industries"));
const Integration = lazy(() => import("./pages/Settings/Integration"));
@@ -258,7 +257,6 @@ export default function App() {
<Route path="/settings/subscriptions" element={<Subscriptions />} />
<Route path="/settings/system" element={<SystemSettings />} />
<Route path="/settings/account" element={<AccountSettings />} />
<Route path="/settings/ai" element={<AISettings />} />
<Route path="/settings/plans" element={<Plans />} />
<Route path="/settings/industries" element={<Industries />} />
{/* AI Models Settings - Admin Only */}

View File

@@ -1,48 +0,0 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function AISettings() {
const toast = useToast();
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/system/settings/ai/');
setSettings(response.results || []);
} catch (error: any) {
toast.error(`Failed to load AI settings: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="AI Settings" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">AI Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Configure AI models and writing preferences</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<p className="text-gray-600 dark:text-gray-400">AI settings management interface coming soon.</p>
</Card>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff