From dc7a459ebbc55e746c1afb49109467902ea5a50a Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 5 Jan 2026 01:21:52 +0000 Subject: [PATCH] django admin Groups reorg, Frontend udpates for site settings, #Migration runs --- backend/igny8_core/ai/ai_core.py | 80 +- backend/igny8_core/ai/constants.py | 17 +- .../ai/functions/generate_image_prompts.py | 28 +- .../ai/functions/generate_images.py | 43 +- backend/igny8_core/ai/model_registry.py | 248 ++-- backend/igny8_core/ai/settings.py | 89 +- backend/igny8_core/ai/tasks.py | 63 +- backend/igny8_core/ai/validators.py | 31 +- backend/igny8_core/api/urls.py | 7 +- backend/igny8_core/business/billing/models.py | 337 +++--- .../billing/services/credit_service.py | 168 +++ backend/igny8_core/modules/billing/admin.py | 187 +-- .../0025_add_aimodel_credit_fields.py | 43 + .../0026_populate_aimodel_credits.py | 63 + .../migrations/0027_model_schema_update.py | 356 ++++++ .../igny8_core/modules/billing/serializers.py | 17 + backend/igny8_core/modules/billing/views.py | 2 +- backend/igny8_core/modules/system/__init__.py | 4 +- backend/igny8_core/modules/system/admin.py | 111 +- .../igny8_core/modules/system/ai_settings.py | 195 +++ .../modules/system/global_settings_models.py | 4 +- .../modules/system/integration_views.py | 319 ++--- .../0015_add_integration_provider.py | 86 ++ .../0016_populate_integration_providers.py | 136 +++ .../migrations/0017_create_ai_settings.py | 15 + .../0018_create_ai_settings_table.py | 35 + .../migrations/0019_model_schema_update.py | 91 ++ backend/igny8_core/modules/system/models.py | 136 +++ .../modules/system/settings_admin.py | 22 +- .../modules/system/settings_models.py | 37 +- .../modules/system/settings_serializers.py | 19 + .../modules/system/settings_views.py | 187 ++- backend/igny8_core/settings.py | 3 +- backend/igny8_core/utils/ai_processor.py | 80 +- docs/plans/4th-jan-refactor/django-plan.md | 50 +- .../4th-jan-refactor/final-model-schemas.md | 314 +++++ frontend/src/App.tsx | 2 - frontend/src/pages/Settings/AI.tsx | 48 - frontend/src/pages/Sites/Settings.tsx | 1058 +++++++---------- 39 files changed, 3142 insertions(+), 1589 deletions(-) create mode 100644 backend/igny8_core/modules/billing/migrations/0025_add_aimodel_credit_fields.py create mode 100644 backend/igny8_core/modules/billing/migrations/0026_populate_aimodel_credits.py create mode 100644 backend/igny8_core/modules/billing/migrations/0027_model_schema_update.py create mode 100644 backend/igny8_core/modules/system/ai_settings.py create mode 100644 backend/igny8_core/modules/system/migrations/0015_add_integration_provider.py create mode 100644 backend/igny8_core/modules/system/migrations/0016_populate_integration_providers.py create mode 100644 backend/igny8_core/modules/system/migrations/0017_create_ai_settings.py create mode 100644 backend/igny8_core/modules/system/migrations/0018_create_ai_settings_table.py create mode 100644 backend/igny8_core/modules/system/migrations/0019_model_schema_update.py create mode 100644 docs/plans/4th-jan-refactor/final-model-schemas.md delete mode 100644 frontend/src/pages/Settings/AI.tsx diff --git a/backend/igny8_core/ai/ai_core.py b/backend/igny8_core/ai/ai_core.py index 1885e8b4..c81c9990 100644 --- a/backend/igny8_core/ai/ai_core.py +++ b/backend/igny8_core/ai/ai_core.py @@ -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,24 +164,24 @@ 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())}" - logger.error(f"[AICore] {error_msg}") - tracker.error('ConfigurationError', error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } + # 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 { + 'content': None, + 'error': error_msg, + 'input_tokens': 0, + 'output_tokens': 0, + 'total_tokens': 0, + 'model': active_model, + 'cost': 0.0, + 'api_id': None, + } tracker.ai_call(f"Using model: {active_model}") @@ -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 diff --git a/backend/igny8_core/ai/constants.py b/backend/igny8_core/ai/constants.py index 2458c41b..be8dac97 100644 --- a/backend/igny8_core/ai/constants.py +++ b/backend/igny8_core/ai/constants.py @@ -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, diff --git a/backend/igny8_core/ai/functions/generate_image_prompts.py b/backend/igny8_core/ai/functions/generate_image_prompts.py index 3474075f..0b286533 100644 --- a/backend/igny8_core/ai/functions/generate_image_prompts.py +++ b/backend/igny8_core/ai/functions/generate_image_prompts.py @@ -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: diff --git a/backend/igny8_core/ai/functions/generate_images.py b/backend/igny8_core/ai/functions/generate_images.py index b548708a..514bec83 100644 --- a/backend/igny8_core/ai/functions/generate_images.py +++ b/backend/igny8_core/ai/functions/generate_images.py @@ -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: diff --git a/backend/igny8_core/ai/model_registry.py b/backend/igny8_core/ai/model_registry.py index d0d3f597..c6a188ca 100644 --- a/backend/igny8_core/ai/model_registry.py +++ b/backend/igny8_core/ai/model_registry.py @@ -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 + + # 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.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' + 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}") diff --git a/backend/igny8_core/ai/settings.py b/backend/igny8_core/ai/settings.py index b233979a..b3c435eb 100644 --- a/backend/igny8_core/ai/settings.py +++ b/backend/igny8_core/ai/settings.py @@ -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) diff --git a/backend/igny8_core/ai/tasks.py b/backend/igny8_core/ai/tasks.py index 96bdb7c3..549698eb 100644 --- a/backend/igny8_core/ai/tasks.py +++ b/backend/igny8_core/ai/tasks.py @@ -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 "***" diff --git a/backend/igny8_core/ai/validators.py b/backend/igny8_core/ai/validators.py index 4314e00f..baa10b26 100644 --- a/backend/igny8_core/ai/validators.py +++ b/backend/igny8_core/ai/validators.py @@ -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: - return { - 'valid': False, - 'error': f'Model "{model}" is not in supported models list' - } - elif model_type == 'image': - if model not in VALID_OPENAI_IMAGE_MODELS: - return { - 'valid': False, - 'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.' - } - - return {'valid': True} + 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'Error validating model: {e}' + } def validate_image_size(size: str, model: str) -> Dict[str, Any]: diff --git a/backend/igny8_core/api/urls.py b/backend/igny8_core/api/urls.py index 62a1099d..ac097126 100644 --- a/backend/igny8_core/api/urls.py +++ b/backend/igny8_core/api/urls.py @@ -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//', TeamManagementViewSet.as_view({'delete': 'destroy'}), name='team-detail'), @@ -27,4 +32,4 @@ urlpatterns = [ path('dashboard/stats/', DashboardStatsViewSet.as_view({'get': 'stats'}), name='dashboard-stats'), path('', include(router.urls)), -] +] \ No newline at end of file diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index 7950dd18..3f7ed6ab 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -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") - - if not self.input_cost_per_1m or not self.output_cost_per_1m: - raise ValueError(f"Model {self.model_name} missing cost_per_1m values") - - cost = ( - (Decimal(input_tokens) * self.input_cost_per_1m) + - (Decimal(output_tokens) * self.output_cost_per_1m) - ) / Decimal('1000000') - - return cost + @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() - 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) + @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() - 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') diff --git a/backend/igny8_core/business/billing/services/credit_service.py b/backend/igny8_core/business/billing/services/credit_service.py index 660ae6cc..07a347f0 100644 --- a/backend/igny8_core/business/billing/services/credit_service.py +++ b/backend/igny8_core/business/billing/services/credit_service.py @@ -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): """ @@ -323,4 +439,56 @@ 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 + ) diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 32ec906b..1bd46f91 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -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( - '{} tokens/credit', - color, - obj.tokens_per_credit + '{} credits', + obj.base_credits ) - tokens_per_credit_display.short_description = 'Token Ratio' + base_credits_display.short_description = 'Credits' - 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' - + def is_active_icon(self, obj): + """Active status icon""" + if obj.is_active: return format_html( - '{} ({} → {})', - icon, - color, - obj.previous_tokens_per_credit, - obj.tokens_per_credit + '' ) - 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( + '' + ) + 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( - '' - '${} / ${} per 1M', - obj.input_cost_per_1m, - obj.output_cost_per_1m + '{} tokens/credit', + obj.tokens_per_credit ) - elif obj.model_type == 'image': + elif obj.model_type == 'image' and obj.credits_per_image: return format_html( - '' - '${} per image', - obj.cost_per_image + '{} credits/image', + 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' diff --git a/backend/igny8_core/modules/billing/migrations/0025_add_aimodel_credit_fields.py b/backend/igny8_core/modules/billing/migrations/0025_add_aimodel_credit_fields.py new file mode 100644 index 00000000..fa8fffed --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0025_add_aimodel_credit_fields.py @@ -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), + ), + ] diff --git a/backend/igny8_core/modules/billing/migrations/0026_populate_aimodel_credits.py b/backend/igny8_core/modules/billing/migrations/0026_populate_aimodel_credits.py new file mode 100644 index 00000000..553ca79b --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0026_populate_aimodel_credits.py @@ -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), + ] diff --git a/backend/igny8_core/modules/billing/migrations/0027_model_schema_update.py b/backend/igny8_core/modules/billing/migrations/0027_model_schema_update.py new file mode 100644 index 00000000..c3ea7121 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0027_model_schema_update.py @@ -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), + ), + ] diff --git a/backend/igny8_core/modules/billing/serializers.py b/backend/igny8_core/modules/billing/serializers.py index 2bee0b57..73e07ad1 100644 --- a/backend/igny8_core/modules/billing/serializers.py +++ b/backend/igny8_core/modules/billing/serializers.py @@ -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) diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index aa0e3b3a..e795206b 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -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""" diff --git a/backend/igny8_core/modules/system/__init__.py b/backend/igny8_core/modules/system/__init__.py index 7b79e815..6e9abda4 100644 --- a/backend/igny8_core/modules/system/__init__.py +++ b/backend/igny8_core/modules/system/__init__.py @@ -12,8 +12,10 @@ __all__ = [ 'Strategy', # Global settings models 'GlobalIntegrationSettings', - 'AccountIntegrationOverride', 'GlobalAIPrompt', 'GlobalAuthorProfile', 'GlobalStrategy', + # New centralized models + 'IntegrationProvider', + 'AISettings', ] diff --git a/backend/igny8_core/modules/system/admin.py b/backend/igny8_core/modules/system/admin.py index b9625cb9..1c94223d 100644 --- a/backend/igny8_core/modules/system/admin.py +++ b/backend/igny8_core/modules/system/admin.py @@ -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) + diff --git a/backend/igny8_core/modules/system/ai_settings.py b/backend/igny8_core/modules/system/ai_settings.py new file mode 100644 index 00000000..2dafb6a1 --- /dev/null +++ b/backend/igny8_core/modules/system/ai_settings.py @@ -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 diff --git a/backend/igny8_core/modules/system/global_settings_models.py b/backend/igny8_core/modules/system/global_settings_models.py index 321469f6..920c81a3 100644 --- a/backend/igny8_core/modules/system/global_settings_models.py +++ b/backend/igny8_core/modules/system/global_settings_models.py @@ -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] diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index fe54ae39..145b488b 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -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 - - # Build clean config with only allowed overrides - clean_config = {} + from .settings_models import AccountSettings + + # 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] + # 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, + key=account_key, + defaults={'config': {'value': config[config_key]}} + ) + saved_keys.append(account_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, - account=account, - defaults={'config': clean_config, 'is_active': True} - ) - logger.info(f"[save_settings] Result: created={created}, id={integration_settings.id}") - - 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 diff --git a/backend/igny8_core/modules/system/migrations/0015_add_integration_provider.py b/backend/igny8_core/modules/system/migrations/0015_add_integration_provider.py new file mode 100644 index 00000000..898b15aa --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0015_add_integration_provider.py @@ -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'], + }, + ), + ] diff --git a/backend/igny8_core/modules/system/migrations/0016_populate_integration_providers.py b/backend/igny8_core/modules/system/migrations/0016_populate_integration_providers.py new file mode 100644 index 00000000..f264e94d --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0016_populate_integration_providers.py @@ -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), + ] diff --git a/backend/igny8_core/modules/system/migrations/0017_create_ai_settings.py b/backend/igny8_core/modules/system/migrations/0017_create_ai_settings.py new file mode 100644 index 00000000..afc3f5ee --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0017_create_ai_settings.py @@ -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 + ] diff --git a/backend/igny8_core/modules/system/migrations/0018_create_ai_settings_table.py b/backend/igny8_core/modules/system/migrations/0018_create_ai_settings_table.py new file mode 100644 index 00000000..4a92a283 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0018_create_ai_settings_table.py @@ -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', + }, + ), + ] diff --git a/backend/igny8_core/modules/system/migrations/0019_model_schema_update.py b/backend/igny8_core/modules/system/migrations/0019_model_schema_update.py new file mode 100644 index 00000000..52fd0156 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0019_model_schema_update.py @@ -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', + # ), + ] diff --git a/backend/igny8_core/modules/system/models.py b/backend/igny8_core/modules/system/models.py index 36e2847b..82c7267c 100644 --- a/backend/igny8_core/modules/system/models.py +++ b/backend/igny8_core/modules/system/models.py @@ -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. diff --git a/backend/igny8_core/modules/system/settings_admin.py b/backend/igny8_core/modules/system/settings_admin.py index d6dacef7..2ab4682b 100644 --- a/backend/igny8_core/modules/system/settings_admin.py +++ b/backend/igny8_core/modules/system/settings_admin.py @@ -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: diff --git a/backend/igny8_core/modules/system/settings_models.py b/backend/igny8_core/modules/system/settings_models.py index dd79a0cc..abc8eae8 100644 --- a/backend/igny8_core/modules/system/settings_models.py +++ b/backend/igny8_core/modules/system/settings_models.py @@ -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' diff --git a/backend/igny8_core/modules/system/settings_serializers.py b/backend/igny8_core/modules/system/settings_serializers.py index 6ae830df..97e45f0f 100644 --- a/backend/igny8_core/modules/system/settings_serializers.py +++ b/backend/igny8_core/modules/system/settings_serializers.py @@ -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) diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index a7b69212..400c5973 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -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 + ) diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 6c1fe8d5..b181439a 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -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/"}, diff --git a/backend/igny8_core/utils/ai_processor.py b/backend/igny8_core/utils/ai_processor.py index fa64e201..d7e17ce6 100644 --- a/backend/igny8_core/utils/ai_processor.py +++ b/backend/igny8_core/utils/ai_processor.py @@ -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: - 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') - if api_key: - logger.info(f"Loaded {integration_type} API key from IntegrationSettings for account {account.id}") - return api_key - except Exception as e: - logger.warning(f"Could not load {integration_type} API key from IntegrationSettings: {e}", exc_info=True) + def _get_api_key(self, integration_type: str) -> Optional[str]: + """Get API key from IntegrationProvider (centralized)""" + try: + from igny8_core.ai.model_registry import ModelRegistry + api_key = ModelRegistry.get_api_key(integration_type) + if api_key: + 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 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': - 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") - except Exception as e: - logger.warning(f"Could not load {integration_type} model from IntegrationSettings: {e}", exc_info=True) + def _get_model(self, integration_type: str) -> str: + """Get default model from AIModelConfig (is_default=True)""" + try: + 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 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( diff --git a/docs/plans/4th-jan-refactor/django-plan.md b/docs/plans/4th-jan-refactor/django-plan.md index 33ac3cb5..bda29f25 100644 --- a/docs/plans/4th-jan-refactor/django-plan.md +++ b/docs/plans/4th-jan-refactor/django-plan.md @@ -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) diff --git a/docs/plans/4th-jan-refactor/final-model-schemas.md b/docs/plans/4th-jan-refactor/final-model-schemas.md new file mode 100644 index 00000000..1ff6d2f7 --- /dev/null +++ b/docs/plans/4th-jan-refactor/final-model-schemas.md @@ -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 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ff82ec7..dec5f257 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> - } /> } /> } /> {/* AI Models Settings - Admin Only */} diff --git a/frontend/src/pages/Settings/AI.tsx b/frontend/src/pages/Settings/AI.tsx deleted file mode 100644 index a6b1c365..00000000 --- a/frontend/src/pages/Settings/AI.tsx +++ /dev/null @@ -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([]); - 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 ( -
- -
-

AI Settings

-

Configure AI models and writing preferences

-
- - {loading ? ( -
-
Loading...
-
- ) : ( - -

AI settings management interface coming soon.

-
- )} -
- ); -} - diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx index 111e206e..80338232 100644 --- a/frontend/src/pages/Sites/Settings.tsx +++ b/frontend/src/pages/Sites/Settings.tsx @@ -15,6 +15,7 @@ import InputField from '../../components/form/input/InputField'; import Select from '../../components/form/Select'; import SelectDropdown from '../../components/form/SelectDropdown'; import Checkbox from '../../components/form/input/Checkbox'; +import Radio from '../../components/form/input/Radio'; import TextArea from '../../components/form/input/TextArea'; import Switch from '../../components/form/switch/Switch'; import { useToast } from '../../components/ui/toast/ToastContainer'; @@ -29,7 +30,7 @@ import { import { useSiteStore } from '../../store/siteStore'; import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm'; import { integrationApi, SiteIntegration } from '../../services/integration.api'; -import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon, ArrowRightIcon, SettingsIcon, GlobeIcon, LayersIcon, CheckCircleIcon } from '../../icons'; +import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon, ArrowRightIcon, SettingsIcon, GlobeIcon, LayersIcon, CheckCircleIcon, CalendarIcon, InfoIcon } from '../../icons'; import Badge from '../../components/ui/badge/Badge'; import { Dropdown } from '../../components/ui/dropdown/Dropdown'; import { DropdownItem } from '../../components/ui/dropdown/DropdownItem'; @@ -53,9 +54,9 @@ export default function SiteSettings() { const [isSiteSelectorOpen, setIsSiteSelectorOpen] = useState(false); const siteSelectorRef = useRef(null); - // Check for tab parameter in URL - now includes content-generation and image-settings tabs - const initialTab = (searchParams.get('tab') as 'general' | 'content-generation' | 'image-settings' | 'integrations' | 'publishing' | 'content-types') || 'general'; - const [activeTab, setActiveTab] = useState<'general' | 'content-generation' | 'image-settings' | 'integrations' | 'publishing' | 'content-types'>(initialTab); + // Check for tab parameter in URL - image-settings merged into content-generation (renamed to ai-settings) + const initialTab = (searchParams.get('tab') as 'general' | 'ai-settings' | 'integrations' | 'publishing' | 'content-types') || 'general'; + const [activeTab, setActiveTab] = useState<'general' | 'ai-settings' | 'integrations' | 'publishing' | 'content-types'>(initialTab); const [contentTypes, setContentTypes] = useState(null); const [contentTypesLoading, setContentTypesLoading] = useState(false); @@ -67,7 +68,8 @@ export default function SiteSettings() { const [publishingSettingsLoading, setPublishingSettingsLoading] = useState(false); const [publishingSettingsSaving, setPublishingSettingsSaving] = useState(false); - // Content Generation Settings state + // AI Settings state (merged content generation + image settings per plan) + // Content writing settings const [contentGenerationSettings, setContentGenerationSettings] = useState({ appendToPrompt: '', defaultTone: 'professional', @@ -76,95 +78,21 @@ export default function SiteSettings() { const [contentGenerationLoading, setContentGenerationLoading] = useState(false); const [contentGenerationSaving, setContentGenerationSaving] = useState(false); - // Image Settings state - const [imageQuality, setImageQuality] = useState<'basic' | 'quality' | 'premium'>('basic'); - const [imageSettings, setImageSettings] = useState({ - enabled: true, - service: 'runware' as 'runware', - provider: 'runware', - model: 'runware:97@1', - image_type: 'photorealistic' as string, - max_in_article_images: 2, - image_format: 'webp' as 'webp' | 'jpg' | 'png', - }); - const [imageSettingsLoading, setImageSettingsLoading] = useState(false); - const [imageSettingsSaving, setImageSettingsSaving] = useState(false); + // AI Parameters (from SystemAISettings) + const [temperature, setTemperature] = useState(0.7); + const [maxTokens, setMaxTokens] = useState(8192); - // Current image provider (from GlobalIntegrationSettings) - const [imageProvider, setImageProvider] = useState<'runware' | 'openai'>('runware'); - - // Available style options loaded from backend (dynamic based on provider) + // Image Generation settings (from SystemAISettings + AIModelConfig) + const [qualityTiers, setQualityTiers] = useState>([]); + const [selectedTier, setSelectedTier] = useState('quality'); const [availableStyles, setAvailableStyles] = useState>([ { value: 'photorealistic', label: 'Photorealistic', description: 'Ultra realistic photography style' }, ]); - - // Image quality to config mapping - Runware models only with model-specific sizes - const QUALITY_TO_CONFIG: Record = { - basic: { service: 'runware', model: 'runware:97@1' }, // 6 credits/image - quality: { service: 'runware', model: 'bria:10@1' }, // 10 credits/image - premium: { service: 'runware', model: 'google:4@2' }, // 15 credits/image - }; - - // Runware model choices with credits per image (from backend AIModelConfig) - const RUNWARE_MODEL_CHOICES = [ - { value: 'runware:97@1', label: 'Basic (6 credits/image)', credits: 6 }, - { value: 'bria:10@1', label: 'Quality (10 credits/image)', credits: 10 }, - { value: 'google:4@2', label: 'Premium (15 credits/image)', credits: 15 }, - ]; - - // OpenAI DALL-E model choices - const DALLE_MODEL_CHOICES = [ - { value: 'dall-e-3', label: 'DALL-E 3 - HD Quality', credits: 40 }, - ]; - - // Model-specific style options (image_type attribute compatible with each model) - const getModelStyleOptions = (model: string) => { - // Bria supports medium parameter (photography/art) - if (model === 'bria:10@1') { - return [ - { value: 'photography', label: 'Photography' }, - { value: 'art', label: 'Artistic' }, - ]; - } - // All models support these basic styles via prompt modification - return [ - { value: 'realistic', label: 'Realistic' }, - { value: 'artistic', label: 'Artistic' }, - { value: 'cartoon', label: 'Cartoon' }, - ]; - }; - - const getQualityFromConfig = (service?: string, model?: string): 'basic' | 'quality' | 'premium' => { - if (model === 'google:4@2') return 'premium'; - if (model === 'bria:10@1') return 'quality'; - return 'basic'; // Default to basic (runware:97@1) - }; - - // Model-specific landscape sizes (used for featured image) - const MODEL_LANDSCAPE_SIZES: Record = { - 'runware:97@1': '1280x768', // Hi Dream Full - 'bria:10@1': '1344x768', // Bria 3.2 - 'google:4@2': '1376x768', // Nano Banana - 'dall-e-3': '1792x1024', // DALL-E 3 - 'dall-e-2': '1024x1024', // DALL-E 2 (square only) - }; - - const getLandscapeSizeForModel = (model: string): string => { - return MODEL_LANDSCAPE_SIZES[model] || (imageProvider === 'openai' ? '1792x1024' : '1280x768'); - }; - - const getCurrentImageConfig = useCallback(() => { - // For OpenAI provider, return the DALL-E model directly - if (imageProvider === 'openai') { - return { service: 'openai', model: imageSettings.model || 'dall-e-3' }; - } - // For Runware provider, use quality-based config - const config = QUALITY_TO_CONFIG[imageQuality]; - return { service: config.service, model: config.model }; - }, [imageQuality, imageProvider, imageSettings.model]); - - // Get the current model's landscape size for display - const currentLandscapeSize = getLandscapeSizeForModel(imageSettings.model || getCurrentImageConfig().model); + const [selectedStyle, setSelectedStyle] = useState('photorealistic'); + const [maxImages, setMaxImages] = useState(4); + const [maxAllowed, setMaxAllowed] = useState(8); + const [aiSettingsLoading, setAiSettingsLoading] = useState(false); + const [aiSettingsSaving, setAiSettingsSaving] = useState(false); // Sectors selection state const [industries, setIndustries] = useState([]); @@ -217,9 +145,13 @@ export default function SiteSettings() { useEffect(() => { // Update tab if URL parameter changes const tab = searchParams.get('tab'); - if (tab && ['general', 'content-generation', 'image-settings', 'integrations', 'publishing', 'content-types'].includes(tab)) { + if (tab && ['general', 'ai-settings', 'integrations', 'publishing', 'content-types'].includes(tab)) { setActiveTab(tab as typeof activeTab); } + // Handle legacy tab names + if (tab === 'content-generation' || tab === 'image-settings') { + setActiveTab('ai-settings'); + } }, [searchParams]); useEffect(() => { @@ -234,31 +166,14 @@ export default function SiteSettings() { } }, [activeTab, siteId]); - // Load content generation settings when tab is active + // Load AI settings when tab is active (merged content generation + image settings) useEffect(() => { - if (activeTab === 'content-generation' && siteId) { + if (activeTab === 'ai-settings' && siteId) { + loadAISettings(); loadContentGenerationSettings(); } }, [activeTab, siteId]); - // Load image settings when tab is active - useEffect(() => { - if (activeTab === 'image-settings' && siteId) { - loadImageSettings(); - } - }, [activeTab, siteId]); - - // Update image config when quality changes (all Runware models) - useEffect(() => { - const config = getCurrentImageConfig(); - setImageSettings(prev => ({ - ...prev, - service: config.service, - provider: config.service, - model: config.model, - })); - }, [imageQuality, getCurrentImageConfig]); - // Load sites for selector useEffect(() => { loadSites(); @@ -429,116 +344,69 @@ export default function SiteSettings() { } }; - // Image Settings - const loadImageSettings = async () => { + // AI Settings (new merged API for temperature, max tokens, image quality/style) + const loadAISettings = async () => { try { - setImageSettingsLoading(true); - const response = await fetchAPI('/v1/system/settings/integrations/image_generation/'); - // API returns { data: { config: {...} } } structure - try multiple paths - const config = response?.data?.config || response?.config || response?.data || response || {}; + setAiSettingsLoading(true); + const response = await fetchAPI('/v1/account/settings/ai/'); - console.log('[loadImageSettings] Raw response:', response); - console.log('[loadImageSettings] Extracted config:', config); + // Set content generation params (temperature, max_tokens) + if (response?.content_generation) { + setTemperature(response.content_generation.temperature ?? 0.7); + setMaxTokens(response.content_generation.max_tokens ?? 8192); + } - if (config) { - // Get provider from config (GlobalIntegrationSettings default_image_service) - const provider = config.provider || config.service || 'runware'; - setImageProvider(provider as 'runware' | 'openai'); - - // Load available styles from backend (provider-specific) - // If not available from API, use default fallbacks - if (config.available_styles && Array.isArray(config.available_styles) && config.available_styles.length > 0) { - setAvailableStyles(config.available_styles); - console.log('[loadImageSettings] Loaded styles from API:', config.available_styles); - } else { - // Fallback styles if API doesn't return them (backward compatibility) - const fallbackStyles = provider === 'openai' - ? [ - { value: 'natural', label: 'Natural', description: 'More realistic, photographic style' }, - { value: 'vivid', label: 'Vivid', description: 'Hyper-real, dramatic, artistic' }, - ] - : [ - { value: 'photorealistic', label: 'Photorealistic', description: 'Ultra realistic photography style, natural lighting, real world look' }, - { value: 'illustration', label: 'Illustration', description: 'Digital illustration, clean lines, artistic but not realistic' }, - { value: '3d_render', label: '3D Render', description: 'Computer generated 3D style, modern, polished, depth and lighting' }, - { value: 'minimal_flat', label: 'Minimal / Flat Design', description: 'Simple shapes, flat colors, modern UI and graphic design look' }, - { value: 'artistic', label: 'Artistic / Painterly', description: 'Expressive, painted or hand drawn aesthetic' }, - { value: 'cartoon', label: 'Cartoon / Stylized', description: 'Playful, exaggerated forms, animated or mascot style' }, - ]; - setAvailableStyles(fallbackStyles); - console.log('[loadImageSettings] Using fallback styles for provider:', provider); - } - - // Get model based on provider - let loadedModel = config.model || 'runware:97@1'; - if (provider === 'openai') { - loadedModel = config.model || 'dall-e-3'; - } else { - loadedModel = config.model || config.runwareModel || 'runware:97@1'; - } - - const quality = getQualityFromConfig(provider, loadedModel); - setImageQuality(quality); - - // Get image_type from config - map old values to new ones - let imageType = config.image_type || (provider === 'openai' ? 'natural' : 'photorealistic'); - // Map old style values to new ones - if (imageType === 'realistic') imageType = provider === 'openai' ? 'natural' : 'photorealistic'; - - setImageSettings({ - enabled: config.enabled !== false, - service: provider, - provider: provider, - model: loadedModel, - image_type: imageType, - max_in_article_images: config.max_in_article_images || 4, - image_format: config.image_format || 'webp', - }); - - console.log('[loadImageSettings] Final settings - Provider:', provider, 'Model:', loadedModel, 'Quality:', quality, 'ImageType:', imageType); + // Set image generation params + if (response?.image_generation) { + setQualityTiers(response.image_generation.quality_tiers || []); + setSelectedTier(response.image_generation.selected_tier || 'quality'); + setAvailableStyles(response.image_generation.styles || []); + setSelectedStyle(response.image_generation.selected_style || 'photorealistic'); + setMaxImages(response.image_generation.max_images ?? 4); + setMaxAllowed(response.image_generation.max_allowed ?? 4); } } catch (error: any) { - console.error('Error loading image settings:', error); + console.error('Error loading AI settings:', error); } finally { - setImageSettingsLoading(false); + setAiSettingsLoading(false); } }; - const saveImageSettings = async () => { + const saveAISettings = async () => { try { - setImageSettingsSaving(true); - const config = getCurrentImageConfig(); - const landscapeSize = getLandscapeSizeForModel(imageSettings.model); - const configToSave = { - enabled: imageSettings.enabled, - service: imageProvider, - provider: imageProvider, - model: imageSettings.model, - runwareModel: imageProvider === 'runware' ? imageSettings.model : undefined, - image_type: imageSettings.image_type, - max_in_article_images: imageSettings.max_in_article_images, - image_format: imageSettings.image_format, - featured_image_size: landscapeSize, // Auto-determined by model - }; - - console.log('[saveImageSettings] Saving config:', configToSave); - - // URL pattern is /v1/system/settings/integrations//save/ - const result = await fetchAPI('/v1/system/settings/integrations/image_generation/save/', { + setAiSettingsSaving(true); + await fetchAPI('/v1/account/settings/ai/', { method: 'POST', - body: JSON.stringify(configToSave), + body: JSON.stringify({ + content_generation: { + temperature, + max_tokens: maxTokens, + }, + image_generation: { + quality_tier: selectedTier, + image_style: selectedStyle, + max_images_per_article: maxImages, + }, + }), }); - - console.log('[saveImageSettings] Save result:', result); - toast.success('Image settings saved successfully'); + toast.success('AI settings saved successfully'); } catch (error: any) { - console.error('Error saving image settings:', error); - toast.error(`Failed to save settings: ${error.message}`); + console.error('Error saving AI settings:', error); + toast.error(`Failed to save AI settings: ${error.message}`); } finally { - setImageSettingsSaving(false); + setAiSettingsSaving(false); } }; + // Legacy image settings functions (kept for backward compatibility but not used in AI Settings tab) + const loadImageSettings = async () => { + // No longer used - AI settings now loaded via loadAISettings() + }; + + const saveImageSettings = async () => { + // No longer used - AI settings now saved via saveAISettings() + }; + const loadIndustries = async () => { try { const response = await fetchIndustries(); @@ -844,33 +712,18 @@ export default function SiteSettings() { - + setActiveTab('ai-settings'); + navigate(`/sites/${siteId}/settings?tab=ai-settings`, { replace: true }); + }} + className={`px-4 py-2 font-medium border-b-2 rounded-none transition-colors whitespace-nowrap ${ + activeTab === 'ai-settings' + ? 'border-success-500 text-success-600 dark:text-success-400' + : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' + }`} + startIcon={} + > + AI Settings +