django admin Groups reorg, Frontend udpates for site settings, #Migration runs
This commit is contained in:
@@ -13,8 +13,6 @@ from django.conf import settings
|
|||||||
from .constants import (
|
from .constants import (
|
||||||
DEFAULT_AI_MODEL,
|
DEFAULT_AI_MODEL,
|
||||||
JSON_MODE_MODELS,
|
JSON_MODE_MODELS,
|
||||||
MODEL_RATES,
|
|
||||||
IMAGE_MODEL_RATES,
|
|
||||||
VALID_OPENAI_IMAGE_MODELS,
|
VALID_OPENAI_IMAGE_MODELS,
|
||||||
VALID_SIZES_BY_MODEL,
|
VALID_SIZES_BY_MODEL,
|
||||||
DEBUG_MODE,
|
DEBUG_MODE,
|
||||||
@@ -45,21 +43,18 @@ class AICore:
|
|||||||
self._load_account_settings()
|
self._load_account_settings()
|
||||||
|
|
||||||
def _load_account_settings(self):
|
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:
|
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
|
# Load API keys from IntegrationProvider (centralized, platform-wide)
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
self._openai_api_key = ModelRegistry.get_api_key('openai')
|
||||||
|
self._runware_api_key = ModelRegistry.get_api_key('runware')
|
||||||
# Load API keys from global settings (platform-wide)
|
self._bria_api_key = ModelRegistry.get_api_key('bria')
|
||||||
self._openai_api_key = global_settings.openai_api_key
|
self._anthropic_api_key = ModelRegistry.get_api_key('anthropic')
|
||||||
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)
|
|
||||||
|
|
||||||
except Exception as e:
|
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._openai_api_key = None
|
||||||
self._runware_api_key = None
|
self._runware_api_key = None
|
||||||
self._bria_api_key = None
|
self._bria_api_key = None
|
||||||
@@ -169,24 +164,24 @@ class AICore:
|
|||||||
logger.info(f" - Model used in request: {active_model}")
|
logger.info(f" - Model used in request: {active_model}")
|
||||||
tracker.ai_call(f"Using model: {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
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
if not ModelRegistry.validate_model(active_model):
|
if not ModelRegistry.validate_model(active_model):
|
||||||
# Fallback check against constants for backward compatibility
|
# Get list of supported models from database
|
||||||
if active_model not in MODEL_RATES:
|
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: {list(MODEL_RATES.keys())}"
|
error_msg = f"Model '{active_model}' is not supported. Supported models: {supported_models}"
|
||||||
logger.error(f"[AICore] {error_msg}")
|
logger.error(f"[AICore] {error_msg}")
|
||||||
tracker.error('ConfigurationError', error_msg)
|
tracker.error('ConfigurationError', error_msg)
|
||||||
return {
|
return {
|
||||||
'content': None,
|
'content': None,
|
||||||
'error': error_msg,
|
'error': error_msg,
|
||||||
'input_tokens': 0,
|
'input_tokens': 0,
|
||||||
'output_tokens': 0,
|
'output_tokens': 0,
|
||||||
'total_tokens': 0,
|
'total_tokens': 0,
|
||||||
'model': active_model,
|
'model': active_model,
|
||||||
'cost': 0.0,
|
'cost': 0.0,
|
||||||
'api_id': None,
|
'api_id': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracker.ai_call(f"Using model: {active_model}")
|
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"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})")
|
||||||
tracker.parse(f"Content length: {len(content)} characters")
|
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
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
cost = float(ModelRegistry.calculate_cost(
|
cost = float(ModelRegistry.calculate_cost(
|
||||||
active_model,
|
active_model,
|
||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
output_tokens=output_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.parse(f"Cost calculated: ${cost:.6f}")
|
||||||
|
|
||||||
tracker.done("Request completed successfully")
|
tracker.done("Request completed successfully")
|
||||||
@@ -902,11 +893,9 @@ class AICore:
|
|||||||
image_url = image_data.get('url')
|
image_url = image_data.get('url')
|
||||||
revised_prompt = image_data.get('revised_prompt')
|
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
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
cost = float(ModelRegistry.calculate_cost(model, num_images=n))
|
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 5: Image generated successfully")
|
||||||
print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}")
|
print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}")
|
||||||
print(f"[AI][{function_name}][Success] Image generation completed")
|
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:
|
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
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
|
|
||||||
if model_type == 'text':
|
if model_type == 'text':
|
||||||
cost = float(ModelRegistry.calculate_cost(model, input_tokens=input_tokens, output_tokens=output_tokens))
|
return 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
|
|
||||||
elif model_type == 'image':
|
elif model_type == 'image':
|
||||||
cost = float(ModelRegistry.calculate_cost(model, num_images=1))
|
return 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 0.0
|
return 0.0
|
||||||
|
|
||||||
# Legacy method names for backward compatibility
|
# Legacy method names for backward compatibility
|
||||||
|
|||||||
@@ -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 = {
|
MODEL_RATES = {
|
||||||
'gpt-4.1': {'input': 2.00, 'output': 8.00},
|
'gpt-4.1': {'input': 2.00, 'output': 8.00},
|
||||||
'gpt-4o-mini': {'input': 0.15, 'output': 0.60},
|
'gpt-4o-mini': {'input': 0.15, 'output': 0.60},
|
||||||
@@ -10,7 +20,8 @@ MODEL_RATES = {
|
|||||||
'gpt-5.2': {'input': 1.75, 'output': 14.00},
|
'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 = {
|
IMAGE_MODEL_RATES = {
|
||||||
'dall-e-3': 0.040,
|
'dall-e-3': 0.040,
|
||||||
'dall-e-2': 0.020,
|
'dall-e-2': 0.020,
|
||||||
|
|||||||
@@ -219,32 +219,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
|||||||
# Helper methods
|
# Helper methods
|
||||||
def _get_max_in_article_images(self, account) -> int:
|
def _get_max_in_article_images(self, account) -> int:
|
||||||
"""
|
"""
|
||||||
Get max_in_article_images from settings.
|
Get max_in_article_images from AISettings (with account override).
|
||||||
Uses account's IntegrationSettings override, or GlobalIntegrationSettings.
|
|
||||||
"""
|
"""
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
from igny8_core.modules.system.ai_settings import AISettings
|
||||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
|
||||||
|
|
||||||
# Try account-specific override first
|
max_images = AISettings.get_effective_max_images(account)
|
||||||
try:
|
logger.info(f"Using max_in_article_images={max_images} for account {account.id}")
|
||||||
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})")
|
|
||||||
return max_images
|
return max_images
|
||||||
|
|
||||||
def _extract_content_elements(self, content: Content, max_images: int) -> Dict:
|
def _extract_content_elements(self, content: Content, max_images: int) -> Dict:
|
||||||
|
|||||||
@@ -67,40 +67,33 @@ class GenerateImagesFunction(BaseAIFunction):
|
|||||||
if not tasks:
|
if not tasks:
|
||||||
raise ValueError("No tasks found")
|
raise ValueError("No tasks found")
|
||||||
|
|
||||||
# Get image generation settings
|
# Get image generation settings from AISettings (with account overrides)
|
||||||
# Try account-specific override, otherwise use GlobalIntegrationSettings
|
from igny8_core.modules.system.ai_settings import AISettings
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
|
||||||
|
|
||||||
image_settings = {}
|
# Get effective settings (AISettings + AccountSettings overrides)
|
||||||
try:
|
image_style = AISettings.get_effective_image_style(account)
|
||||||
integration = IntegrationSettings.objects.get(
|
max_images = AISettings.get_effective_max_images(account)
|
||||||
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")
|
|
||||||
|
|
||||||
# Use GlobalIntegrationSettings for missing values
|
# Get default image model and provider from database
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
default_model = ModelRegistry.get_default_model('image')
|
||||||
|
if default_model:
|
||||||
# Extract settings with defaults from global settings
|
model_config = ModelRegistry.get_model(default_model)
|
||||||
provider = image_settings.get('provider') or image_settings.get('service') or global_settings.default_image_service
|
provider = model_config.provider if model_config else 'openai'
|
||||||
if provider == 'runware':
|
model = default_model
|
||||||
model = image_settings.get('model') or image_settings.get('runwareModel') or global_settings.runware_model
|
|
||||||
else:
|
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 {
|
return {
|
||||||
'tasks': tasks,
|
'tasks': tasks,
|
||||||
'account': account,
|
'account': account,
|
||||||
'provider': provider,
|
'provider': provider,
|
||||||
'model': model,
|
'model': model,
|
||||||
'image_type': image_settings.get('image_type') or global_settings.image_style,
|
'image_type': image_style,
|
||||||
'max_in_article_images': int(image_settings.get('max_in_article_images') or global_settings.max_in_article_images),
|
'max_in_article_images': max_images,
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_prompt(self, data: Dict, account=None) -> Dict:
|
def build_prompt(self, data: Dict, account=None) -> Dict:
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Model Registry Service
|
Model Registry Service
|
||||||
Central registry for AI model configurations with caching.
|
Central registry for AI model configurations with caching.
|
||||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
|
|
||||||
|
|
||||||
This service provides:
|
This service provides:
|
||||||
- Database-driven model configuration (from AIModelConfig)
|
- Database-driven model configuration (from AIModelConfig)
|
||||||
- Fallback to constants.py for backward compatibility
|
- Integration provider API key retrieval (from IntegrationProvider)
|
||||||
- Caching for performance
|
- Caching for performance
|
||||||
- Cost calculation methods
|
- Cost calculation methods
|
||||||
|
|
||||||
@@ -20,6 +19,9 @@ Usage:
|
|||||||
|
|
||||||
# Calculate cost
|
# Calculate cost
|
||||||
cost = ModelRegistry.calculate_cost('gpt-4o-mini', input_tokens=1000, output_tokens=500)
|
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
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -33,12 +35,14 @@ MODEL_CACHE_TTL = 300
|
|||||||
|
|
||||||
# Cache key prefix
|
# Cache key prefix
|
||||||
CACHE_KEY_PREFIX = 'ai_model_'
|
CACHE_KEY_PREFIX = 'ai_model_'
|
||||||
|
PROVIDER_CACHE_PREFIX = 'provider_'
|
||||||
|
|
||||||
|
|
||||||
class ModelRegistry:
|
class ModelRegistry:
|
||||||
"""
|
"""
|
||||||
Central registry for AI model configurations with caching.
|
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
|
@classmethod
|
||||||
@@ -46,6 +50,11 @@ class ModelRegistry:
|
|||||||
"""Generate cache key for model"""
|
"""Generate cache key for model"""
|
||||||
return f"{CACHE_KEY_PREFIX}{model_id}"
|
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
|
@classmethod
|
||||||
def _get_from_db(cls, model_id: str) -> Optional[Any]:
|
def _get_from_db(cls, model_id: str) -> Optional[Any]:
|
||||||
"""Get model config from database"""
|
"""Get model config from database"""
|
||||||
@@ -59,46 +68,6 @@ class ModelRegistry:
|
|||||||
logger.debug(f"Could not fetch model {model_id} from DB: {e}")
|
logger.debug(f"Could not fetch model {model_id} from DB: {e}")
|
||||||
return None
|
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
|
@classmethod
|
||||||
def get_model(cls, model_id: str) -> Optional[Any]:
|
def get_model(cls, model_id: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
@@ -107,13 +76,12 @@ class ModelRegistry:
|
|||||||
Order of lookup:
|
Order of lookup:
|
||||||
1. Cache
|
1. Cache
|
||||||
2. Database (AIModelConfig)
|
2. Database (AIModelConfig)
|
||||||
3. constants.py fallback
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_id: The model identifier (e.g., 'gpt-4o-mini', 'dall-e-3')
|
model_id: The model identifier (e.g., 'gpt-4o-mini', 'dall-e-3')
|
||||||
|
|
||||||
Returns:
|
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)
|
cache_key = cls._get_cache_key(model_id)
|
||||||
|
|
||||||
@@ -129,13 +97,7 @@ class ModelRegistry:
|
|||||||
cache.set(cache_key, model_config, MODEL_CACHE_TTL)
|
cache.set(cache_key, model_config, MODEL_CACHE_TTL)
|
||||||
return model_config
|
return model_config
|
||||||
|
|
||||||
# Fallback to constants
|
logger.warning(f"Model {model_id} not found in database")
|
||||||
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")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -154,16 +116,6 @@ class ModelRegistry:
|
|||||||
if not model:
|
if not model:
|
||||||
return Decimal('0')
|
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
|
# Handle AIModelConfig instance
|
||||||
if rate_type == 'input':
|
if rate_type == 'input':
|
||||||
return model.input_cost_per_1m or Decimal('0')
|
return model.input_cost_per_1m or Decimal('0')
|
||||||
@@ -195,8 +147,8 @@ class ModelRegistry:
|
|||||||
if not model:
|
if not model:
|
||||||
return Decimal('0')
|
return Decimal('0')
|
||||||
|
|
||||||
# Determine model type
|
# Get model type from AIModelConfig
|
||||||
model_type = model.get('model_type') if isinstance(model, dict) else model.model_type
|
model_type = model.model_type
|
||||||
|
|
||||||
if model_type == 'text':
|
if model_type == 'text':
|
||||||
input_rate = cls.get_rate(model_id, 'input')
|
input_rate = cls.get_rate(model_id, 'input')
|
||||||
@@ -218,7 +170,7 @@ class ModelRegistry:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_default_model(cls, model_type: str = 'text') -> Optional[str]:
|
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:
|
Args:
|
||||||
model_type: 'text' or 'image'
|
model_type: 'text' or 'image'
|
||||||
@@ -236,32 +188,33 @@ class ModelRegistry:
|
|||||||
|
|
||||||
if default:
|
if default:
|
||||||
return default.model_name
|
return default.model_name
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not get default {model_type} model from DB: {e}")
|
|
||||||
|
|
||||||
# Fallback to constants
|
# If no default is set, return first active model of this type
|
||||||
from igny8_core.ai.constants import DEFAULT_AI_MODEL
|
first_active = AIModelConfig.objects.filter(
|
||||||
if model_type == 'text':
|
model_type=model_type,
|
||||||
return DEFAULT_AI_MODEL
|
is_active=True
|
||||||
elif model_type == 'image':
|
).order_by('model_name').first()
|
||||||
return 'dall-e-3'
|
|
||||||
|
if first_active:
|
||||||
|
return first_active.model_name
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not get default {model_type} model from DB: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list_models(cls, model_type: Optional[str] = None, provider: Optional[str] = None) -> list:
|
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:
|
Args:
|
||||||
model_type: Filter by 'text', 'image', or 'embedding'
|
model_type: Filter by 'text', 'image', or 'embedding'
|
||||||
provider: Filter by 'openai', 'anthropic', 'runware', etc.
|
provider: Filter by 'openai', 'anthropic', 'runware', etc.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of model configs
|
List of AIModelConfig instances
|
||||||
"""
|
"""
|
||||||
models = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from igny8_core.business.billing.models import AIModelConfig
|
from igny8_core.business.billing.models import AIModelConfig
|
||||||
queryset = AIModelConfig.objects.filter(is_active=True)
|
queryset = AIModelConfig.objects.filter(is_active=True)
|
||||||
@@ -271,27 +224,10 @@ class ModelRegistry:
|
|||||||
if provider:
|
if provider:
|
||||||
queryset = queryset.filter(provider=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:
|
except Exception as e:
|
||||||
logger.debug(f"Could not list models from DB: {e}")
|
logger.error(f"Could not list models from DB: {e}")
|
||||||
|
return []
|
||||||
# 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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_cache(cls, model_id: Optional[str] = None):
|
def clear_cache(cls, model_id: Optional[str] = None):
|
||||||
@@ -311,10 +247,10 @@ class ModelRegistry:
|
|||||||
if hasattr(default_cache, 'delete_pattern'):
|
if hasattr(default_cache, 'delete_pattern'):
|
||||||
default_cache.delete_pattern(f"{CACHE_KEY_PREFIX}*")
|
default_cache.delete_pattern(f"{CACHE_KEY_PREFIX}*")
|
||||||
else:
|
else:
|
||||||
# Fallback: clear known models
|
# Fallback: clear all known models from DB
|
||||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
from igny8_core.business.billing.models import AIModelConfig
|
||||||
for model_id in list(MODEL_RATES.keys()) + list(IMAGE_MODEL_RATES.keys()):
|
for model in AIModelConfig.objects.values_list('model_name', flat=True):
|
||||||
cache.delete(cls._get_cache_key(model_id))
|
cache.delete(cls._get_cache_key(model))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not clear all model caches: {e}")
|
logger.warning(f"Could not clear all model caches: {e}")
|
||||||
|
|
||||||
@@ -332,8 +268,110 @@ class ModelRegistry:
|
|||||||
model = cls.get_model(model_id)
|
model = cls.get_model(model_id)
|
||||||
if not model:
|
if not model:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if active
|
|
||||||
if isinstance(model, dict):
|
|
||||||
return model.get('is_active', True)
|
|
||||||
return model.is_active
|
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}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
AI Settings - Centralized model configurations and limits
|
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
|
from typing import Dict, Any
|
||||||
import logging
|
import logging
|
||||||
@@ -22,10 +23,9 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
|||||||
Get model configuration for AI function.
|
Get model configuration for AI function.
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- API keys: ALWAYS from GlobalIntegrationSettings (platform-wide)
|
- API keys: From IntegrationProvider (centralized)
|
||||||
- Model/params: From IntegrationSettings if account has override, else from global
|
- Model: From AIModelConfig (is_default=True)
|
||||||
- Free plan: Cannot override, uses global defaults
|
- Params: From AISettings with AccountSettings overrides
|
||||||
- Starter/Growth/Scale: Can override model, temperature, max_tokens, etc.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
function_name: Name of the AI function
|
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)
|
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
from igny8_core.modules.system.ai_settings import AISettings
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
|
|
||||||
# Get global settings (for API keys and defaults)
|
# Get API key from IntegrationProvider
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
api_key = ModelRegistry.get_api_key('openai')
|
||||||
|
|
||||||
if not global_settings.openai_api_key:
|
if not api_key:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Platform OpenAI API key not configured. "
|
"Platform OpenAI API key not configured. "
|
||||||
"Please configure GlobalIntegrationSettings in Django admin."
|
"Please configure IntegrationProvider in Django admin."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start with global defaults
|
# Get default text model from AIModelConfig
|
||||||
model = global_settings.openai_model
|
default_model = ModelRegistry.get_default_model('text')
|
||||||
temperature = global_settings.openai_temperature
|
if not default_model:
|
||||||
api_key = global_settings.openai_api_key # ALWAYS from global
|
default_model = 'gpt-4o-mini' # Ultimate fallback
|
||||||
|
|
||||||
# Get max_tokens from AIModelConfig for the selected model
|
model = default_model
|
||||||
max_tokens = global_settings.openai_max_tokens # Fallback
|
|
||||||
|
# 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:
|
try:
|
||||||
from igny8_core.business.billing.models import AIModelConfig
|
from igny8_core.business.billing.models import AIModelConfig
|
||||||
model_config = AIModelConfig.objects.filter(
|
model_config = AIModelConfig.objects.filter(
|
||||||
@@ -74,60 +79,22 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not load max_tokens from AIModelConfig for {model}: {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:
|
except Exception as e:
|
||||||
logger.error(f"Could not load OpenAI settings for account {account.id}: {e}")
|
logger.error(f"Could not load OpenAI settings for account {account.id}: {e}")
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Could not load OpenAI configuration for account {account.id}. "
|
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:
|
try:
|
||||||
from igny8_core.utils.ai_processor import MODEL_RATES
|
if not ModelRegistry.validate_model(model):
|
||||||
if model not in MODEL_RATES:
|
supported_models = [m.model_name for m in ModelRegistry.list_models(model_type='text')]
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Model '{model}' for account {account.id} is not in supported list. "
|
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
|
pass
|
||||||
|
|
||||||
# Build response format based on model (JSON mode for supported models)
|
# Build response format based on model (JSON mode for supported models)
|
||||||
|
|||||||
@@ -181,42 +181,26 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
failed = 0
|
failed = 0
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Get image generation settings
|
# Get image generation settings from AISettings (with account overrides)
|
||||||
# Try account-specific override, otherwise use GlobalIntegrationSettings
|
|
||||||
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
|
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 = {}
|
# Get effective settings
|
||||||
try:
|
image_type = AISettings.get_effective_image_style(account)
|
||||||
image_settings = IntegrationSettings.objects.get(
|
image_format = 'webp' # Default format
|
||||||
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)}'}
|
|
||||||
|
|
||||||
# Use GlobalIntegrationSettings for missing values
|
# Get default image model from database
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
default_model = ModelRegistry.get_default_model('image')
|
||||||
|
if default_model:
|
||||||
logger.info(f"[process_image_generation_queue] Image generation settings loaded. Config keys: {list(config.keys())}")
|
model_config = ModelRegistry.get_model(default_model)
|
||||||
logger.info(f"[process_image_generation_queue] Full config: {config}")
|
provider = model_config.provider if model_config else 'openai'
|
||||||
|
model = default_model
|
||||||
# Get provider and model from config with global fallbacks
|
|
||||||
provider = config.get('provider') or global_settings.default_image_service
|
|
||||||
if provider == 'runware':
|
|
||||||
model = config.get('model') or config.get('imageModel') or global_settings.runware_model
|
|
||||||
else:
|
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")
|
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
|
# Style to prompt enhancement mapping
|
||||||
# These style descriptors are added to the image prompt for better results
|
# 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" - Featured image size: {featured_image_size}")
|
||||||
logger.info(f" - In-article square: {in_article_square_size}, landscape: {in_article_landscape_size}")
|
logger.info(f" - In-article square: {in_article_square_size}, landscape: {in_article_landscape_size}")
|
||||||
|
|
||||||
# Get provider API key
|
# Get provider API key from IntegrationProvider (centralized)
|
||||||
# API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys)
|
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key from IntegrationProvider")
|
||||||
# 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 API key from GlobalIntegrationSettings
|
# Get API key from IntegrationProvider (centralized)
|
||||||
if provider == 'runware':
|
api_key = ModelRegistry.get_api_key(provider)
|
||||||
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
|
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
logger.error(f"[process_image_generation_queue] {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 in GlobalIntegrationSettings'}
|
return {'success': False, 'error': f'{provider.upper()} API key not configured'}
|
||||||
|
|
||||||
# Log API key presence (but not the actual key for security)
|
# 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 "***"
|
api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***"
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
|
|||||||
Dict with 'valid' (bool) and optional 'error' (str)
|
Dict with 'valid' (bool) and optional 'error' (str)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try database first
|
# Use database-driven validation via AIModelConfig
|
||||||
from igny8_core.business.billing.models import AIModelConfig
|
from igny8_core.business.billing.models import AIModelConfig
|
||||||
|
|
||||||
exists = AIModelConfig.objects.filter(
|
exists = AIModelConfig.objects.filter(
|
||||||
@@ -169,29 +169,20 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
'valid': False,
|
'valid': False,
|
||||||
'error': f'Model "{model}" is not found in database'
|
'error': f'No {model_type} models configured in database'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {'valid': True}
|
return {'valid': True}
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# Fallback to constants if database fails
|
# Log error but don't fallback to constants - DB is authoritative
|
||||||
from .constants import MODEL_RATES, VALID_OPENAI_IMAGE_MODELS
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
if model_type == 'text':
|
logger.error(f"Error validating model {model}: {e}")
|
||||||
if model not in MODEL_RATES:
|
return {
|
||||||
return {
|
'valid': False,
|
||||||
'valid': False,
|
'error': f'Error validating model: {e}'
|
||||||
'error': f'Model "{model}" is not in supported models list'
|
}
|
||||||
}
|
|
||||||
elif model_type == 'image':
|
|
||||||
if model not in VALID_OPENAI_IMAGE_MODELS:
|
|
||||||
return {
|
|
||||||
'valid': False,
|
|
||||||
'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {'valid': True}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_image_size(size: str, model: str) -> Dict[str, Any]:
|
def validate_image_size(size: str, model: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .account_views import (
|
|||||||
UsageAnalyticsViewSet,
|
UsageAnalyticsViewSet,
|
||||||
DashboardStatsViewSet
|
DashboardStatsViewSet
|
||||||
)
|
)
|
||||||
|
from igny8_core.modules.system.settings_views import ContentGenerationSettingsViewSet
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ urlpatterns = [
|
|||||||
# Account settings (non-router endpoints for simplified access)
|
# Account settings (non-router endpoints for simplified access)
|
||||||
path('settings/', AccountSettingsViewSet.as_view({'get': 'retrieve', 'patch': 'partial_update'}), name='account-settings'),
|
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
|
# Team management
|
||||||
path('team/', TeamManagementViewSet.as_view({'get': 'list', 'post': 'create'}), name='team-list'),
|
path('team/', TeamManagementViewSet.as_view({'get': 'list', 'post': 'create'}), name='team-list'),
|
||||||
path('team/<int:pk>/', TeamManagementViewSet.as_view({'delete': 'destroy'}), name='team-detail'),
|
path('team/<int:pk>/', TeamManagementViewSet.as_view({'delete': 'destroy'}), name='team-detail'),
|
||||||
|
|||||||
@@ -114,65 +114,48 @@ class CreditUsageLog(AccountBaseModel):
|
|||||||
|
|
||||||
class CreditCostConfig(models.Model):
|
class CreditCostConfig(models.Model):
|
||||||
"""
|
"""
|
||||||
Token-based credit pricing configuration.
|
Fixed credit costs per operation type.
|
||||||
ALL operations use token-to-credit conversion.
|
|
||||||
|
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(
|
operation_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
unique=True,
|
unique=True,
|
||||||
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
|
primary_key=True,
|
||||||
help_text="AI operation type"
|
help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Token-to-credit ratio (tokens per 1 credit)
|
# Human-readable name
|
||||||
tokens_per_credit = models.IntegerField(
|
display_name = models.CharField(
|
||||||
default=100,
|
max_length=100,
|
||||||
validators=[MinValueValidator(1)],
|
help_text="Human-readable name"
|
||||||
help_text="Number of tokens that equal 1 credit (e.g., 100 tokens = 1 credit)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minimum credits (for very small token usage)
|
# Fixed credits per operation
|
||||||
min_credits = models.IntegerField(
|
base_credits = models.IntegerField(
|
||||||
default=1,
|
default=1,
|
||||||
validators=[MinValueValidator(0)],
|
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
|
# Status
|
||||||
is_active = models.BooleanField(default=True, help_text="Enable/disable this operation")
|
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Change tracking
|
# Admin notes
|
||||||
previous_tokens_per_credit = models.IntegerField(
|
description = models.TextField(
|
||||||
null=True,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Tokens per credit before last update (for audit trail)"
|
help_text="Admin notes about this operation"
|
||||||
)
|
)
|
||||||
|
|
||||||
# History tracking
|
# History tracking
|
||||||
@@ -186,18 +169,7 @@ class CreditCostConfig(models.Model):
|
|||||||
ordering = ['operation_type']
|
ordering = ['operation_type']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.display_name} - {self.tokens_per_credit} tokens/credit"
|
return f"{self.display_name} - {self.base_credits} credits"
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class BillingConfiguration(models.Model):
|
class BillingConfiguration(models.Model):
|
||||||
@@ -696,18 +668,34 @@ class AccountPaymentMethod(AccountBaseModel):
|
|||||||
|
|
||||||
class AIModelConfig(models.Model):
|
class AIModelConfig(models.Model):
|
||||||
"""
|
"""
|
||||||
AI Model Configuration - Database-driven model pricing and capabilities.
|
All AI models (text + image) with pricing and credit configuration.
|
||||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
|
Single Source of Truth for Models.
|
||||||
|
|
||||||
Two pricing models:
|
Per final-model-schemas.md:
|
||||||
- Text models: Cost per 1M tokens (input/output), credits calculated AFTER AI call
|
| Field | Type | Required | Notes |
|
||||||
- Image models: Cost per image, credits calculated BEFORE AI call
|
|-------|------|----------|-------|
|
||||||
|
| 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 = [
|
MODEL_TYPE_CHOICES = [
|
||||||
('text', 'Text Generation'),
|
('text', 'Text Generation'),
|
||||||
('image', 'Image Generation'),
|
('image', 'Image Generation'),
|
||||||
('embedding', 'Embedding'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
PROVIDER_CHOICES = [
|
PROVIDER_CHOICES = [
|
||||||
@@ -717,145 +705,112 @@ class AIModelConfig(models.Model):
|
|||||||
('google', 'Google'),
|
('google', 'Google'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
QUALITY_TIER_CHOICES = [
|
||||||
|
('basic', 'Basic'),
|
||||||
|
('quality', 'Quality'),
|
||||||
|
('premium', 'Premium'),
|
||||||
|
]
|
||||||
|
|
||||||
# Basic Information
|
# Basic Information
|
||||||
model_name = models.CharField(
|
model_name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
unique=True,
|
unique=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')"
|
help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')"
|
||||||
)
|
|
||||||
|
|
||||||
display_name = models.CharField(
|
|
||||||
max_length=200,
|
|
||||||
help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
model_type = models.CharField(
|
model_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=MODEL_TYPE_CHOICES,
|
choices=MODEL_TYPE_CHOICES,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Type of model - determines which pricing fields are used"
|
help_text="text / image"
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = models.CharField(
|
provider = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=PROVIDER_CHOICES,
|
choices=PROVIDER_CHOICES,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="AI provider (OpenAI, Anthropic, etc.)"
|
help_text="Links to IntegrationProvider"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Text Model Pricing (Only for model_type='text')
|
display_name = models.CharField(
|
||||||
input_cost_per_1m = models.DecimalField(
|
max_length=200,
|
||||||
max_digits=10,
|
help_text="Human-readable name"
|
||||||
decimal_places=4,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
|
||||||
help_text="Cost per 1 million input tokens (USD). For text models only."
|
|
||||||
)
|
|
||||||
|
|
||||||
output_cost_per_1m = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=4,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
|
||||||
help_text="Cost per 1 million output tokens (USD). For text models only."
|
|
||||||
)
|
|
||||||
|
|
||||||
context_window = models.IntegerField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
validators=[MinValueValidator(1)],
|
|
||||||
help_text="Maximum input tokens (context length). For text models only."
|
|
||||||
)
|
|
||||||
|
|
||||||
max_output_tokens = models.IntegerField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
validators=[MinValueValidator(1)],
|
|
||||||
help_text="Maximum output tokens per request. For text models only."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Image Model Pricing (Only for model_type='image')
|
|
||||||
cost_per_image = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=4,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
|
||||||
help_text="Fixed cost per image generation (USD). For image models only."
|
|
||||||
)
|
|
||||||
|
|
||||||
valid_sizes = models.JSONField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Capabilities
|
|
||||||
supports_json_mode = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for models with JSON response format support"
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_vision = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for models that can analyze images"
|
|
||||||
)
|
|
||||||
|
|
||||||
supports_function_calling = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="True for models with function calling capability"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status & Configuration
|
|
||||||
is_active = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
db_index=True,
|
|
||||||
help_text="Enable/disable model without deleting"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is_default = models.BooleanField(
|
is_default = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
db_index=True,
|
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(
|
is_active = models.BooleanField(
|
||||||
default=0,
|
default=True,
|
||||||
help_text="Control order in dropdown lists (lower numbers first)"
|
db_index=True,
|
||||||
|
help_text="Enable/disable"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Metadata
|
# Text Model Pricing (cost per 1K tokens)
|
||||||
description = models.TextField(
|
cost_per_1k_input = models.DecimalField(
|
||||||
blank=True,
|
max_digits=10,
|
||||||
help_text="Admin notes about model usage, strengths, limitations"
|
decimal_places=6,
|
||||||
)
|
|
||||||
|
|
||||||
release_date = models.DateField(
|
|
||||||
null=True,
|
null=True,
|
||||||
blank=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,
|
null=True,
|
||||||
blank=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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=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 tracking
|
||||||
history = HistoricalRecords()
|
history = HistoricalRecords()
|
||||||
@@ -865,7 +820,7 @@ class AIModelConfig(models.Model):
|
|||||||
db_table = 'igny8_ai_model_config'
|
db_table = 'igny8_ai_model_config'
|
||||||
verbose_name = 'AI Model Configuration'
|
verbose_name = 'AI Model Configuration'
|
||||||
verbose_name_plural = 'AI Model Configurations'
|
verbose_name_plural = 'AI Model Configurations'
|
||||||
ordering = ['model_type', 'sort_order', 'model_name']
|
ordering = ['model_type', 'model_name']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['model_type', 'is_active']),
|
models.Index(fields=['model_type', 'is_active']),
|
||||||
models.Index(fields=['provider', 'is_active']),
|
models.Index(fields=['provider', 'is_active']),
|
||||||
@@ -878,52 +833,26 @@ class AIModelConfig(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Ensure only one is_default per model_type"""
|
"""Ensure only one is_default per model_type"""
|
||||||
if self.is_default:
|
if self.is_default:
|
||||||
# Unset other defaults for same model_type
|
|
||||||
AIModelConfig.objects.filter(
|
AIModelConfig.objects.filter(
|
||||||
model_type=self.model_type,
|
model_type=self.model_type,
|
||||||
is_default=True
|
is_default=True
|
||||||
).exclude(pk=self.pk).update(is_default=False)
|
).exclude(pk=self.pk).update(is_default=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_cost_for_tokens(self, input_tokens, output_tokens):
|
@classmethod
|
||||||
"""Calculate cost for text models based on token usage"""
|
def get_default_text_model(cls):
|
||||||
if self.model_type != 'text':
|
"""Get the default text generation model"""
|
||||||
raise ValueError("get_cost_for_tokens only applies to text models")
|
return cls.objects.filter(model_type='text', is_default=True, is_active=True).first()
|
||||||
|
|
||||||
if not self.input_cost_per_1m or not self.output_cost_per_1m:
|
@classmethod
|
||||||
raise ValueError(f"Model {self.model_name} missing cost_per_1m values")
|
def get_default_image_model(cls):
|
||||||
|
"""Get the default image generation model"""
|
||||||
|
return cls.objects.filter(model_type='image', is_default=True, is_active=True).first()
|
||||||
|
|
||||||
cost = (
|
@classmethod
|
||||||
(Decimal(input_tokens) * self.input_cost_per_1m) +
|
def get_image_models_by_tier(cls):
|
||||||
(Decimal(output_tokens) * self.output_cost_per_1m)
|
"""Get all active image models grouped by quality tier"""
|
||||||
) / Decimal('1000000')
|
return cls.objects.filter(
|
||||||
|
model_type='image',
|
||||||
return cost
|
is_active=True
|
||||||
|
).order_by('quality_tier', 'model_name')
|
||||||
def get_cost_for_images(self, num_images):
|
|
||||||
"""Calculate cost for image models"""
|
|
||||||
if self.model_type != 'image':
|
|
||||||
raise ValueError("get_cost_for_images only applies to image models")
|
|
||||||
|
|
||||||
if not self.cost_per_image:
|
|
||||||
raise ValueError(f"Model {self.model_name} missing cost_per_image")
|
|
||||||
|
|
||||||
return self.cost_per_image * Decimal(num_images)
|
|
||||||
|
|
||||||
def validate_size(self, size):
|
|
||||||
"""Check if size is valid for this image model"""
|
|
||||||
if self.model_type != 'image':
|
|
||||||
raise ValueError("validate_size only applies to image models")
|
|
||||||
|
|
||||||
if not self.valid_sizes:
|
|
||||||
return True # No size restrictions
|
|
||||||
|
|
||||||
return size in self.valid_sizes
|
|
||||||
|
|
||||||
def get_display_with_pricing(self):
|
|
||||||
"""For dropdowns: show model with pricing"""
|
|
||||||
if self.model_type == 'text':
|
|
||||||
return f"{self.display_name} - ${self.input_cost_per_1m}/${self.output_cost_per_1m} per 1M"
|
|
||||||
elif self.model_type == 'image':
|
|
||||||
return f"{self.display_name} - ${self.cost_per_image} per image"
|
|
||||||
return self.display_name
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Credit Service for managing credit transactions and deductions
|
Credit Service for managing credit transactions and deductions
|
||||||
"""
|
"""
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
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.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||||
from igny8_core.auth.models import Account
|
from igny8_core.auth.models import Account
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CreditService:
|
class CreditService:
|
||||||
"""Service for managing credits - Token-based only"""
|
"""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
|
@staticmethod
|
||||||
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
|
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
|
||||||
"""
|
"""
|
||||||
@@ -324,3 +440,55 @@ class CreditService:
|
|||||||
|
|
||||||
return account.credits
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -552,19 +552,18 @@ class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(CreditCostConfig)
|
@admin.register(CreditCostConfig)
|
||||||
class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin for Credit Cost Configuration.
|
||||||
|
Per final-model-schemas.md - Fixed credits per operation type.
|
||||||
|
"""
|
||||||
list_display = [
|
list_display = [
|
||||||
'operation_type',
|
'operation_type',
|
||||||
'display_name',
|
'display_name',
|
||||||
'tokens_per_credit_display',
|
'base_credits_display',
|
||||||
'price_per_credit_usd',
|
'is_active_icon',
|
||||||
'min_credits',
|
|
||||||
'is_active',
|
|
||||||
'cost_change_indicator',
|
|
||||||
'updated_at',
|
|
||||||
'updated_by'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
list_filter = ['is_active', 'updated_at']
|
list_filter = ['is_active']
|
||||||
search_fields = ['operation_type', 'display_name', 'description']
|
search_fields = ['operation_type', 'display_name', 'description']
|
||||||
actions = ['bulk_activate', 'bulk_deactivate']
|
actions = ['bulk_activate', 'bulk_deactivate']
|
||||||
|
|
||||||
@@ -572,60 +571,30 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
('Operation', {
|
('Operation', {
|
||||||
'fields': ('operation_type', 'display_name', 'description')
|
'fields': ('operation_type', 'display_name', 'description')
|
||||||
}),
|
}),
|
||||||
('Token-to-Credit Configuration', {
|
('Credits', {
|
||||||
'fields': ('tokens_per_credit', 'min_credits', 'price_per_credit_usd', 'is_active'),
|
'fields': ('base_credits', 'is_active'),
|
||||||
'description': 'Configure how tokens are converted to credits for this operation'
|
'description': 'Fixed credits charged per operation'
|
||||||
}),
|
|
||||||
('Audit Trail', {
|
|
||||||
'fields': ('previous_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
|
|
||||||
'classes': ('collapse',)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
|
def base_credits_display(self, obj):
|
||||||
|
"""Show base credits with formatting"""
|
||||||
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)
|
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
|
'<span style="font-weight: bold;">{} credits</span>',
|
||||||
color,
|
obj.base_credits
|
||||||
obj.tokens_per_credit
|
|
||||||
)
|
)
|
||||||
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(
|
return format_html(
|
||||||
'{} <span style="color: {};">({} → {})</span>',
|
'<span style="color: green; font-size: 18px;" title="Active">●</span>'
|
||||||
icon,
|
|
||||||
color,
|
|
||||||
obj.previous_tokens_per_credit,
|
|
||||||
obj.tokens_per_credit
|
|
||||||
)
|
)
|
||||||
return '—'
|
return format_html(
|
||||||
cost_change_indicator.short_description = 'Recent Change'
|
'<span style="color: red; font-size: 18px;" title="Inactive">●</span>'
|
||||||
|
)
|
||||||
def save_model(self, request, obj, form, change):
|
is_active_icon.short_description = 'Active'
|
||||||
"""Track who made the change"""
|
|
||||||
obj.updated_by = request.user
|
|
||||||
super().save_model(request, obj, form, change)
|
|
||||||
|
|
||||||
@admin.action(description='Activate selected configurations')
|
@admin.action(description='Activate selected configurations')
|
||||||
def bulk_activate(self, request, queryset):
|
def bulk_activate(self, request, queryset):
|
||||||
@@ -763,67 +732,60 @@ class BillingConfigurationAdmin(Igny8ModelAdmin):
|
|||||||
@admin.register(AIModelConfig)
|
@admin.register(AIModelConfig)
|
||||||
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Admin for AI Model Configuration - Database-driven model pricing
|
Admin for AI Model Configuration - Single Source of Truth for Models.
|
||||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES
|
Per final-model-schemas.md
|
||||||
"""
|
"""
|
||||||
list_display = [
|
list_display = [
|
||||||
'model_name',
|
'model_name',
|
||||||
'display_name_short',
|
'display_name_short',
|
||||||
'model_type_badge',
|
'model_type_badge',
|
||||||
'provider_badge',
|
'provider_badge',
|
||||||
'pricing_display',
|
'credit_display',
|
||||||
|
'quality_tier',
|
||||||
'is_active_icon',
|
'is_active_icon',
|
||||||
'is_default_icon',
|
'is_default_icon',
|
||||||
'sort_order',
|
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
list_filter = [
|
list_filter = [
|
||||||
'model_type',
|
'model_type',
|
||||||
'provider',
|
'provider',
|
||||||
|
'quality_tier',
|
||||||
'is_active',
|
'is_active',
|
||||||
'is_default',
|
'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 = (
|
fieldsets = (
|
||||||
('Basic Information', {
|
('Basic Information', {
|
||||||
'fields': ('model_name', 'display_name', 'model_type', 'provider', 'description'),
|
'fields': ('model_name', 'model_type', 'provider', 'display_name'),
|
||||||
'description': 'Core model identification and classification'
|
'description': 'Core model identification'
|
||||||
}),
|
}),
|
||||||
('Text Model Pricing', {
|
('Text Model Pricing', {
|
||||||
'fields': ('input_cost_per_1m', 'output_cost_per_1m', 'context_window', 'max_output_tokens'),
|
'fields': ('cost_per_1k_input', 'cost_per_1k_output', 'tokens_per_credit', 'max_tokens', 'context_window'),
|
||||||
'description': 'Pricing and limits for TEXT models only (leave blank for image models)',
|
'description': 'For TEXT models only',
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
('Image Model Pricing', {
|
('Image Model Pricing', {
|
||||||
'fields': ('cost_per_image', 'valid_sizes'),
|
'fields': ('credits_per_image', 'quality_tier'),
|
||||||
'description': 'Pricing and configuration for IMAGE models only (leave blank for text models)',
|
'description': 'For IMAGE models only',
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
('Capabilities', {
|
('Capabilities', {
|
||||||
'fields': ('supports_json_mode', 'supports_vision', 'supports_function_calling'),
|
'fields': ('capabilities',),
|
||||||
'description': 'Model features and capabilities'
|
'description': 'JSON: vision, function_calling, json_mode, etc.',
|
||||||
}),
|
|
||||||
('Status & Display', {
|
|
||||||
'fields': ('is_active', 'is_default', 'sort_order'),
|
|
||||||
'description': 'Control model availability and ordering in dropdowns'
|
|
||||||
}),
|
|
||||||
('Lifecycle', {
|
|
||||||
'fields': ('release_date', 'deprecation_date'),
|
|
||||||
'description': 'Model release and deprecation dates',
|
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
('Audit Trail', {
|
('Status', {
|
||||||
'fields': ('created_at', 'updated_at', 'updated_by'),
|
'fields': ('is_active', 'is_default'),
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -831,8 +793,8 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
# Custom display methods
|
# Custom display methods
|
||||||
def display_name_short(self, obj):
|
def display_name_short(self, obj):
|
||||||
"""Truncated display name for list view"""
|
"""Truncated display name for list view"""
|
||||||
if len(obj.display_name) > 50:
|
if len(obj.display_name) > 40:
|
||||||
return obj.display_name[:47] + '...'
|
return obj.display_name[:37] + '...'
|
||||||
return obj.display_name
|
return obj.display_name
|
||||||
display_name_short.short_description = 'Display Name'
|
display_name_short.short_description = 'Display Name'
|
||||||
|
|
||||||
@@ -841,7 +803,6 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
colors = {
|
colors = {
|
||||||
'text': '#3498db', # Blue
|
'text': '#3498db', # Blue
|
||||||
'image': '#e74c3c', # Red
|
'image': '#e74c3c', # Red
|
||||||
'embedding': '#2ecc71', # Green
|
|
||||||
}
|
}
|
||||||
color = colors.get(obj.model_type, '#95a5a6')
|
color = colors.get(obj.model_type, '#95a5a6')
|
||||||
return format_html(
|
return format_html(
|
||||||
@@ -855,10 +816,10 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
def provider_badge(self, obj):
|
def provider_badge(self, obj):
|
||||||
"""Colored badge for provider"""
|
"""Colored badge for provider"""
|
||||||
colors = {
|
colors = {
|
||||||
'openai': '#10a37f', # OpenAI green
|
'openai': '#10a37f',
|
||||||
'anthropic': '#d97757', # Anthropic orange
|
'anthropic': '#d97757',
|
||||||
'runware': '#6366f1', # Purple
|
'runware': '#6366f1',
|
||||||
'google': '#4285f4', # Google blue
|
'google': '#4285f4',
|
||||||
}
|
}
|
||||||
color = colors.get(obj.provider, '#95a5a6')
|
color = colors.get(obj.provider, '#95a5a6')
|
||||||
return format_html(
|
return format_html(
|
||||||
@@ -869,23 +830,20 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
)
|
)
|
||||||
provider_badge.short_description = 'Provider'
|
provider_badge.short_description = 'Provider'
|
||||||
|
|
||||||
def pricing_display(self, obj):
|
def credit_display(self, obj):
|
||||||
"""Format pricing based on model type"""
|
"""Format credit info based on model type"""
|
||||||
if obj.model_type == 'text':
|
if obj.model_type == 'text' and obj.tokens_per_credit:
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="color: #2c3e50; font-family: monospace;">'
|
'<span style="font-family: monospace;">{} tokens/credit</span>',
|
||||||
'${} / ${} per 1M</span>',
|
obj.tokens_per_credit
|
||||||
obj.input_cost_per_1m,
|
|
||||||
obj.output_cost_per_1m
|
|
||||||
)
|
)
|
||||||
elif obj.model_type == 'image':
|
elif obj.model_type == 'image' and obj.credits_per_image:
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="color: #2c3e50; font-family: monospace;">'
|
'<span style="font-family: monospace;">{} credits/image</span>',
|
||||||
'${} per image</span>',
|
obj.credits_per_image
|
||||||
obj.cost_per_image
|
|
||||||
)
|
)
|
||||||
return '-'
|
return '-'
|
||||||
pricing_display.short_description = 'Pricing'
|
credit_display.short_description = 'Credits'
|
||||||
|
|
||||||
def is_active_icon(self, obj):
|
def is_active_icon(self, obj):
|
||||||
"""Active status icon"""
|
"""Active status icon"""
|
||||||
@@ -915,41 +873,27 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
def bulk_activate(self, request, queryset):
|
def bulk_activate(self, request, queryset):
|
||||||
"""Enable selected models"""
|
"""Enable selected models"""
|
||||||
count = queryset.update(is_active=True)
|
count = queryset.update(is_active=True)
|
||||||
self.message_user(
|
self.message_user(request, f'{count} model(s) activated.', messages.SUCCESS)
|
||||||
request,
|
|
||||||
f'{count} model(s) activated successfully.',
|
|
||||||
messages.SUCCESS
|
|
||||||
)
|
|
||||||
bulk_activate.short_description = 'Activate selected models'
|
bulk_activate.short_description = 'Activate selected models'
|
||||||
|
|
||||||
def bulk_deactivate(self, request, queryset):
|
def bulk_deactivate(self, request, queryset):
|
||||||
"""Disable selected models"""
|
"""Disable selected models"""
|
||||||
count = queryset.update(is_active=False)
|
count = queryset.update(is_active=False)
|
||||||
self.message_user(
|
self.message_user(request, f'{count} model(s) deactivated.', messages.WARNING)
|
||||||
request,
|
|
||||||
f'{count} model(s) deactivated successfully.',
|
|
||||||
messages.WARNING
|
|
||||||
)
|
|
||||||
bulk_deactivate.short_description = 'Deactivate selected models'
|
bulk_deactivate.short_description = 'Deactivate selected models'
|
||||||
|
|
||||||
def set_as_default(self, request, queryset):
|
def set_as_default(self, request, queryset):
|
||||||
"""Set one model as default for its type"""
|
"""Set one model as default for its type"""
|
||||||
if queryset.count() != 1:
|
if queryset.count() != 1:
|
||||||
self.message_user(
|
self.message_user(request, 'Select exactly one model.', messages.ERROR)
|
||||||
request,
|
|
||||||
'Please select exactly one model to set as default.',
|
|
||||||
messages.ERROR
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
model = queryset.first()
|
model = queryset.first()
|
||||||
# Unset other defaults for same type
|
|
||||||
AIModelConfig.objects.filter(
|
AIModelConfig.objects.filter(
|
||||||
model_type=model.model_type,
|
model_type=model.model_type,
|
||||||
is_default=True
|
is_default=True
|
||||||
).exclude(pk=model.pk).update(is_default=False)
|
).exclude(pk=model.pk).update(is_default=False)
|
||||||
|
|
||||||
# Set this as default
|
|
||||||
model.is_default = True
|
model.is_default = True
|
||||||
model.save()
|
model.save()
|
||||||
|
|
||||||
@@ -958,9 +902,4 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
|||||||
f'{model.model_name} is now the default {model.get_model_type_display()} model.',
|
f'{model.model_name} is now the default {model.get_model_type_display()} model.',
|
||||||
messages.SUCCESS
|
messages.SUCCESS
|
||||||
)
|
)
|
||||||
set_as_default.short_description = 'Set as default model (for its type)'
|
set_as_default.short_description = 'Set as default model'
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -255,6 +255,23 @@ class AIModelConfigSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
valid_sizes = serializers.ListField(read_only=True, allow_null=True)
|
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
|
# Capabilities
|
||||||
supports_json_mode = serializers.BooleanField(read_only=True)
|
supports_json_mode = serializers.BooleanField(read_only=True)
|
||||||
supports_vision = serializers.BooleanField(read_only=True)
|
supports_vision = serializers.BooleanField(read_only=True)
|
||||||
|
|||||||
@@ -789,7 +789,7 @@ class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
is_default_bool = is_default.lower() in ['true', '1', 'yes']
|
is_default_bool = is_default.lower() in ['true', '1', 'yes']
|
||||||
queryset = queryset.filter(is_default=is_default_bool)
|
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):
|
def get_serializer_class(self):
|
||||||
"""Return serializer class"""
|
"""Return serializer class"""
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ __all__ = [
|
|||||||
'Strategy',
|
'Strategy',
|
||||||
# Global settings models
|
# Global settings models
|
||||||
'GlobalIntegrationSettings',
|
'GlobalIntegrationSettings',
|
||||||
'AccountIntegrationOverride',
|
|
||||||
'GlobalAIPrompt',
|
'GlobalAIPrompt',
|
||||||
'GlobalAuthorProfile',
|
'GlobalAuthorProfile',
|
||||||
'GlobalStrategy',
|
'GlobalStrategy',
|
||||||
|
# New centralized models
|
||||||
|
'IntegrationProvider',
|
||||||
|
'AISettings',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AIPromptResource(resources.ModelResource):
|
|||||||
# Import settings admin
|
# Import settings admin
|
||||||
from .settings_admin import (
|
from .settings_admin import (
|
||||||
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
|
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
|
||||||
ModuleSettingsAdmin, AISettingsAdmin
|
ModuleSettingsAdmin
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -587,3 +587,112 @@ class GlobalModuleSettingsAdmin(Igny8ModelAdmin):
|
|||||||
'updated_at',
|
'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)
|
||||||
|
|
||||||
|
|||||||
195
backend/igny8_core/modules/system/ai_settings.py
Normal file
195
backend/igny8_core/modules/system/ai_settings.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
AI Settings - System-wide AI defaults (Singleton)
|
||||||
|
|
||||||
|
This is the clean, simplified model for AI configuration.
|
||||||
|
Replaces the deprecated GlobalIntegrationSettings.
|
||||||
|
|
||||||
|
API keys are stored in IntegrationProvider.
|
||||||
|
Model definitions are in AIModelConfig.
|
||||||
|
This model only stores system-wide defaults for AI parameters.
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemAISettings(models.Model):
|
||||||
|
"""
|
||||||
|
System-wide AI defaults. Singleton (pk=1).
|
||||||
|
|
||||||
|
Removed fields (now elsewhere):
|
||||||
|
- All *_api_key fields → IntegrationProvider
|
||||||
|
- All *_model fields → AIModelConfig.is_default
|
||||||
|
- default_text_provider → AIModelConfig.is_default where model_type='text'
|
||||||
|
- default_image_service → AIModelConfig.is_default where model_type='image'
|
||||||
|
|
||||||
|
Accounts can override these via AccountSettings with keys like:
|
||||||
|
- ai.temperature
|
||||||
|
- ai.max_tokens
|
||||||
|
- ai.image_style
|
||||||
|
- ai.image_quality
|
||||||
|
- ai.max_images
|
||||||
|
"""
|
||||||
|
|
||||||
|
IMAGE_STYLE_CHOICES = [
|
||||||
|
('photorealistic', 'Photorealistic'),
|
||||||
|
('illustration', 'Illustration'),
|
||||||
|
('3d_render', '3D Render'),
|
||||||
|
('minimal_flat', 'Minimal / Flat Design'),
|
||||||
|
('artistic', 'Artistic / Painterly'),
|
||||||
|
('cartoon', 'Cartoon / Stylized'),
|
||||||
|
]
|
||||||
|
|
||||||
|
IMAGE_QUALITY_CHOICES = [
|
||||||
|
('standard', 'Standard'),
|
||||||
|
('hd', 'HD'),
|
||||||
|
]
|
||||||
|
|
||||||
|
IMAGE_SIZE_CHOICES = [
|
||||||
|
('1024x1024', '1024x1024 (Square)'),
|
||||||
|
('1792x1024', '1792x1024 (Landscape)'),
|
||||||
|
('1024x1792', '1024x1792 (Portrait)'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# AI Parameters
|
||||||
|
temperature = models.FloatField(
|
||||||
|
default=0.7,
|
||||||
|
help_text="AI temperature (0.0-2.0). Higher = more creative."
|
||||||
|
)
|
||||||
|
max_tokens = models.IntegerField(
|
||||||
|
default=8192,
|
||||||
|
help_text="Max response tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Image Generation Settings
|
||||||
|
image_style = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
default='photorealistic',
|
||||||
|
choices=IMAGE_STYLE_CHOICES,
|
||||||
|
help_text="Default image style"
|
||||||
|
)
|
||||||
|
image_quality = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='standard',
|
||||||
|
choices=IMAGE_QUALITY_CHOICES,
|
||||||
|
help_text="Default image quality (standard/hd)"
|
||||||
|
)
|
||||||
|
max_images_per_article = models.IntegerField(
|
||||||
|
default=4,
|
||||||
|
help_text="Max in-article images (1-8)"
|
||||||
|
)
|
||||||
|
image_size = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='1024x1024',
|
||||||
|
choices=IMAGE_SIZE_CHOICES,
|
||||||
|
help_text="Default image dimensions"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='system_ai_settings_updates'
|
||||||
|
)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'igny8_system_ai_settings'
|
||||||
|
verbose_name = 'System AI Settings'
|
||||||
|
verbose_name_plural = 'System AI Settings'
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Enforce singleton - always use pk=1"""
|
||||||
|
self.pk = 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
"""Prevent deletion of singleton"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls):
|
||||||
|
"""Get or create the singleton instance"""
|
||||||
|
obj, created = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "System AI Settings"
|
||||||
|
|
||||||
|
# Helper methods for getting effective settings with account overrides
|
||||||
|
@classmethod
|
||||||
|
def get_effective_temperature(cls, account=None) -> float:
|
||||||
|
"""Get temperature, checking account override first"""
|
||||||
|
if account:
|
||||||
|
override = cls._get_account_override(account, 'ai.temperature')
|
||||||
|
if override is not None:
|
||||||
|
return float(override)
|
||||||
|
return cls.get_instance().temperature
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_effective_max_tokens(cls, account=None) -> int:
|
||||||
|
"""Get max_tokens, checking account override first"""
|
||||||
|
if account:
|
||||||
|
override = cls._get_account_override(account, 'ai.max_tokens')
|
||||||
|
if override is not None:
|
||||||
|
return int(override)
|
||||||
|
return cls.get_instance().max_tokens
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_effective_image_style(cls, account=None) -> str:
|
||||||
|
"""Get image_style, checking account override first"""
|
||||||
|
if account:
|
||||||
|
override = cls._get_account_override(account, 'ai.image_style')
|
||||||
|
if override is not None:
|
||||||
|
return str(override)
|
||||||
|
return cls.get_instance().image_style
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_effective_image_quality(cls, account=None) -> str:
|
||||||
|
"""Get image_quality, checking account override first"""
|
||||||
|
if account:
|
||||||
|
override = cls._get_account_override(account, 'ai.image_quality')
|
||||||
|
if override is not None:
|
||||||
|
return str(override)
|
||||||
|
return cls.get_instance().image_quality
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_effective_max_images(cls, account=None) -> int:
|
||||||
|
"""Get max_images_per_article, checking account override first"""
|
||||||
|
if account:
|
||||||
|
override = cls._get_account_override(account, 'ai.max_images')
|
||||||
|
if override is not None:
|
||||||
|
return int(override)
|
||||||
|
return cls.get_instance().max_images_per_article
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_effective_image_size(cls, account=None) -> str:
|
||||||
|
"""Get image_size, checking account override first"""
|
||||||
|
if account:
|
||||||
|
override = cls._get_account_override(account, 'ai.image_size')
|
||||||
|
if override is not None:
|
||||||
|
return str(override)
|
||||||
|
return cls.get_instance().image_size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_account_override(account, key: str):
|
||||||
|
"""Get account-specific override from AccountSettings"""
|
||||||
|
try:
|
||||||
|
from igny8_core.modules.system.settings_models import AccountSettings
|
||||||
|
setting = AccountSettings.objects.filter(
|
||||||
|
account=account,
|
||||||
|
key=key
|
||||||
|
).first()
|
||||||
|
if setting and setting.config:
|
||||||
|
return setting.config.get('value')
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get account override for {key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Alias for backward compatibility and clearer naming
|
||||||
|
AISettings = SystemAISettings
|
||||||
@@ -21,7 +21,7 @@ def get_text_model_choices():
|
|||||||
models = AIModelConfig.objects.filter(
|
models = AIModelConfig.objects.filter(
|
||||||
model_type='text',
|
model_type='text',
|
||||||
is_active=True
|
is_active=True
|
||||||
).order_by('sort_order', 'model_name')
|
).order_by('model_name')
|
||||||
|
|
||||||
if models.exists():
|
if models.exists():
|
||||||
return [(m.model_name, m.display_name) for m in models]
|
return [(m.model_name, m.display_name) for m in models]
|
||||||
@@ -48,7 +48,7 @@ def get_image_model_choices(provider=None):
|
|||||||
)
|
)
|
||||||
if provider:
|
if provider:
|
||||||
qs = qs.filter(provider=provider)
|
qs = qs.filter(provider=provider)
|
||||||
qs = qs.order_by('sort_order', 'model_name')
|
qs = qs.order_by('model_name')
|
||||||
|
|
||||||
if qs.exists():
|
if qs.exists():
|
||||||
return [(m.model_name, m.display_name) for m in qs]
|
return [(m.model_name, m.display_name) for m in qs]
|
||||||
|
|||||||
@@ -109,16 +109,15 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
|
|
||||||
# Get platform API keys
|
# Get platform API keys from IntegrationProvider (centralized)
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
api_key = ModelRegistry.get_api_key(integration_type)
|
||||||
|
|
||||||
# Get config from request (model selection)
|
# Get config from request (model selection)
|
||||||
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
|
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
|
||||||
|
|
||||||
if integration_type == 'openai':
|
if integration_type == 'openai':
|
||||||
api_key = global_settings.openai_api_key
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Platform OpenAI API key not configured. Please contact administrator.',
|
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)
|
return self._test_openai(api_key, config, request)
|
||||||
|
|
||||||
elif integration_type == 'runware':
|
elif integration_type == 'runware':
|
||||||
api_key = global_settings.runware_api_key
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Platform Runware API key not configured. Please contact administrator.',
|
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)
|
output_tokens = usage.get('completion_tokens', 0)
|
||||||
total_tokens = usage.get('total_tokens', 0)
|
total_tokens = usage.get('total_tokens', 0)
|
||||||
|
|
||||||
# Calculate cost using model rates (reference plugin: line 274-275)
|
# Calculate cost using ModelRegistry (database-driven)
|
||||||
from igny8_core.utils.ai_processor import MODEL_RATES
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
|
cost = float(ModelRegistry.calculate_cost(
|
||||||
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000
|
model,
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens
|
||||||
|
))
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
@@ -521,31 +522,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
request=request
|
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}")
|
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 = ModelRegistry.get_api_key(provider)
|
||||||
api_key = None
|
integration_enabled = api_key is not None
|
||||||
integration_enabled = False
|
logger.info(f"[generate_image] {provider.upper()} API key: enabled={integration_enabled}, has_key={bool(api_key)}")
|
||||||
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
|
|
||||||
|
|
||||||
# Validate provider and API key
|
# Validate provider and API key
|
||||||
logger.info(f"[generate_image] Step 4: Validating {provider} 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):
|
def save_settings(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
Save integration settings (account overrides only).
|
Save integration settings (account overrides only).
|
||||||
- Saves model/parameter overrides to IntegrationSettings
|
- Saves model/parameter overrides to AccountSettings (key-value store)
|
||||||
- NEVER saves API keys (those are platform-wide)
|
- NEVER saves API keys (those are platform-wide via IntegrationProvider)
|
||||||
- Free plan: Should be blocked at frontend level
|
- Free plan: Should be blocked at frontend level
|
||||||
"""
|
"""
|
||||||
integration_type = pk
|
integration_type = pk
|
||||||
@@ -689,62 +672,47 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
# TODO: Check if Free plan - they shouldn't be able to save overrides
|
# 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
|
# This should be blocked at frontend level, but add backend check too
|
||||||
|
|
||||||
from .models import IntegrationSettings
|
from .settings_models import AccountSettings
|
||||||
|
|
||||||
# Build clean config with only allowed overrides
|
# Save account overrides to AccountSettings (key-value store)
|
||||||
clean_config = {}
|
saved_keys = []
|
||||||
|
|
||||||
if integration_type == 'openai':
|
if integration_type == 'openai':
|
||||||
# Only allow model, temperature, max_tokens overrides
|
# Save OpenAI-specific overrides to AccountSettings
|
||||||
if 'model' in config:
|
key_mappings = {
|
||||||
clean_config['model'] = config['model']
|
'temperature': 'ai.temperature',
|
||||||
if 'temperature' in config:
|
'max_tokens': 'ai.max_tokens',
|
||||||
clean_config['temperature'] = config['temperature']
|
}
|
||||||
if 'max_tokens' in config:
|
for config_key, account_key in key_mappings.items():
|
||||||
clean_config['max_tokens'] = config['max_tokens']
|
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':
|
elif integration_type == 'image_generation':
|
||||||
# Map service to provider if service is provided
|
# Save image generation overrides to AccountSettings
|
||||||
if 'service' in config:
|
key_mappings = {
|
||||||
clean_config['service'] = config['service']
|
'image_type': 'ai.image_style',
|
||||||
clean_config['provider'] = config['service']
|
'image_style': 'ai.image_style',
|
||||||
if 'provider' in config:
|
'image_quality': 'ai.image_quality',
|
||||||
clean_config['provider'] = config['provider']
|
'max_in_article_images': 'ai.max_images',
|
||||||
clean_config['service'] = config['provider']
|
'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)
|
||||||
|
|
||||||
# Model selection (service-specific)
|
logger.info(f"[save_settings] Saved to AccountSettings: {saved_keys}")
|
||||||
if 'model' in config:
|
|
||||||
clean_config['model'] = config['model']
|
|
||||||
if 'imageModel' in config:
|
|
||||||
clean_config['imageModel'] = config['imageModel']
|
|
||||||
clean_config['model'] = config['imageModel'] # Also store in 'model' for consistency
|
|
||||||
if 'runwareModel' in config:
|
|
||||||
clean_config['runwareModel'] = config['runwareModel']
|
|
||||||
|
|
||||||
# Universal image settings (applies to all providers)
|
|
||||||
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
|
|
||||||
'desktop_enabled', 'featured_image_size', 'desktop_image_size']:
|
|
||||||
if key in config:
|
|
||||||
clean_config[key] = config[key]
|
|
||||||
|
|
||||||
# Get or create integration settings
|
|
||||||
logger.info(f"[save_settings] Saving clean config: {clean_config}")
|
|
||||||
integration_settings, created = IntegrationSettings.objects.get_or_create(
|
|
||||||
integration_type=integration_type,
|
|
||||||
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}")
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={'config': clean_config},
|
data={'saved_keys': saved_keys},
|
||||||
message=f'{integration_type.upper()} settings saved successfully',
|
message=f'{integration_type.upper()} settings saved successfully',
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
@@ -787,20 +755,20 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
logger.warning(f"Error getting account from user: {e}")
|
logger.warning(f"Error getting account from user: {e}")
|
||||||
account = None
|
account = None
|
||||||
|
|
||||||
from .models import IntegrationSettings
|
from igny8_core.modules.system.ai_settings import SystemAISettings
|
||||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
|
|
||||||
# Get global defaults
|
# Build response using SystemAISettings (singleton) + AccountSettings overrides
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
|
||||||
|
|
||||||
# Build response with global defaults
|
|
||||||
if integration_type == 'openai':
|
if integration_type == 'openai':
|
||||||
# Get max_tokens from AIModelConfig for the selected model
|
# Get default model from AIModelConfig
|
||||||
max_tokens = global_settings.openai_max_tokens # Fallback
|
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:
|
try:
|
||||||
from igny8_core.business.billing.models import AIModelConfig
|
from igny8_core.business.billing.models import AIModelConfig
|
||||||
model_config = AIModelConfig.objects.filter(
|
model_config = AIModelConfig.objects.filter(
|
||||||
model_name=global_settings.openai_model,
|
model_name=default_model,
|
||||||
is_active=True
|
is_active=True
|
||||||
).first()
|
).first()
|
||||||
if model_config and model_config.max_output_tokens:
|
if model_config and model_config.max_output_tokens:
|
||||||
@@ -811,31 +779,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
response_data = {
|
response_data = {
|
||||||
'id': 'openai',
|
'id': 'openai',
|
||||||
'enabled': True, # Always enabled (platform-wide)
|
'enabled': True, # Always enabled (platform-wide)
|
||||||
'model': global_settings.openai_model,
|
'model': default_model,
|
||||||
'temperature': global_settings.openai_temperature,
|
'temperature': SystemAISettings.get_effective_temperature(account),
|
||||||
'max_tokens': max_tokens,
|
'max_tokens': max_tokens,
|
||||||
'using_global': True, # Flag to show it's using global
|
'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':
|
elif integration_type == 'runware':
|
||||||
response_data = {
|
response_data = {
|
||||||
'id': 'runware',
|
'id': 'runware',
|
||||||
@@ -851,63 +800,35 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
'google:4@2': '1376x768',
|
'google:4@2': '1376x768',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get default service and model based on global settings
|
# Get default image model from AIModelConfig
|
||||||
default_service = global_settings.default_image_service
|
default_model = ModelRegistry.get_default_model('image')
|
||||||
default_model = global_settings.dalle_model if default_service == 'openai' else global_settings.runware_model
|
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')
|
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(default_model, '1280x768')
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'id': 'image_generation',
|
'id': 'image_generation',
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
'service': default_service, # From global settings
|
'service': default_service,
|
||||||
'provider': default_service, # Alias for service
|
'provider': default_service,
|
||||||
'model': default_model, # Service-specific default model
|
'model': default_model,
|
||||||
'imageModel': global_settings.dalle_model, # OpenAI model
|
'imageModel': default_model if default_service == 'openai' else 'dall-e-3',
|
||||||
'runwareModel': global_settings.runware_model, # Runware model
|
'runwareModel': default_model if default_service != 'openai' else None,
|
||||||
'image_type': global_settings.image_style, # Use image_style as default
|
'image_type': SystemAISettings.get_effective_image_style(account),
|
||||||
'image_quality': global_settings.image_quality, # Universal quality
|
'image_quality': SystemAISettings.get_effective_image_quality(account),
|
||||||
'image_style': global_settings.image_style, # Universal style
|
'image_style': SystemAISettings.get_effective_image_style(account),
|
||||||
'max_in_article_images': global_settings.max_in_article_images,
|
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
|
||||||
'image_format': 'webp',
|
'image_format': 'webp',
|
||||||
'desktop_enabled': True,
|
'desktop_enabled': True,
|
||||||
'featured_image_size': model_landscape_size, # Model-specific landscape
|
'featured_image_size': model_landscape_size,
|
||||||
'desktop_image_size': global_settings.desktop_image_size,
|
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
|
||||||
'using_global': True,
|
'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:
|
else:
|
||||||
# Other integration types - return empty
|
# Other integration types - return empty
|
||||||
response_data = {
|
response_data = {
|
||||||
@@ -932,14 +853,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
"""Get image generation settings for current account.
|
"""Get image generation settings for current account.
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
1. If account has IntegrationSettings override -> use it (with GlobalIntegrationSettings as fallback for missing fields)
|
1. SystemAISettings (singleton) provides system-wide defaults
|
||||||
2. Otherwise -> use GlobalIntegrationSettings (platform-wide defaults)
|
2. AccountSettings (key-value) provides per-account overrides
|
||||||
|
3. API keys come from IntegrationProvider (accounts cannot override API keys)
|
||||||
Note: API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys).
|
|
||||||
Account IntegrationSettings only store model/parameter overrides.
|
|
||||||
"""
|
"""
|
||||||
from .models import IntegrationSettings
|
from igny8_core.modules.system.ai_settings import SystemAISettings
|
||||||
from .global_settings_models import GlobalIntegrationSettings
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
|
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
|
|
||||||
@@ -949,10 +868,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||||
account = getattr(user, 'account', None)
|
account = getattr(user, 'account', None)
|
||||||
|
|
||||||
# Get GlobalIntegrationSettings (platform defaults - always available)
|
# Model-specific landscape sizes
|
||||||
global_settings = GlobalIntegrationSettings.get_instance()
|
|
||||||
|
|
||||||
# Model-specific landscape sizes (from GlobalIntegrationSettings)
|
|
||||||
MODEL_LANDSCAPE_SIZES = {
|
MODEL_LANDSCAPE_SIZES = {
|
||||||
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
||||||
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
|
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
|
||||||
@@ -962,53 +878,38 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if account has specific overrides
|
# Get default image model from AIModelConfig
|
||||||
account_config = {}
|
default_model = ModelRegistry.get_default_model('image')
|
||||||
if account:
|
if default_model:
|
||||||
try:
|
model_config = ModelRegistry.get_model(default_model)
|
||||||
integration = IntegrationSettings.objects.get(
|
provider = model_config.provider if model_config else 'openai'
|
||||||
account=account,
|
model = default_model
|
||||||
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
|
|
||||||
else:
|
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
|
# Get model-specific landscape size
|
||||||
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1280x768')
|
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1280x768')
|
||||||
default_featured_size = model_landscape_size if provider == 'runware' else '1792x1024'
|
default_featured_size = model_landscape_size if provider == 'runware' else '1792x1024'
|
||||||
|
|
||||||
# Get image style with provider-specific defaults
|
# Get image style from SystemAISettings with AccountSettings overrides
|
||||||
image_style = account_config.get('image_type') or global_settings.image_style
|
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
|
# Runware: Uses all styles with prompt enhancement
|
||||||
# OpenAI DALL-E: Only supports 'natural' or 'vivid'
|
# OpenAI DALL-E: Only supports 'natural' or 'vivid'
|
||||||
if provider == 'openai':
|
if provider == 'openai':
|
||||||
# Get DALL-E styles from model definition
|
|
||||||
available_styles = [
|
available_styles = [
|
||||||
{'value': opt[0], 'label': opt[1], 'description': opt[2]}
|
{'value': 'vivid', 'label': 'Vivid', 'description': 'Dramatic, hyper-realistic style'},
|
||||||
for opt in GlobalIntegrationSettings.DALLE_STYLE_OPTIONS
|
{'value': 'natural', 'label': 'Natural', 'description': 'Natural, realistic style'},
|
||||||
]
|
]
|
||||||
# Map stored style to DALL-E compatible
|
# Map stored style to DALL-E compatible
|
||||||
if image_style not in ['vivid', 'natural']:
|
if image_style not in ['vivid', 'natural']:
|
||||||
image_style = 'natural' # Default to natural for photorealistic
|
image_style = 'natural' # Default to natural for photorealistic
|
||||||
else:
|
else:
|
||||||
# Get Runware styles from model definition
|
|
||||||
available_styles = [
|
available_styles = [
|
||||||
{'value': opt[0], 'label': opt[1], 'description': opt[2]}
|
{'value': opt[0], 'label': opt[1]}
|
||||||
for opt in GlobalIntegrationSettings.IMAGE_STYLE_OPTIONS
|
for opt in SystemAISettings.IMAGE_STYLE_CHOICES
|
||||||
]
|
]
|
||||||
# Default to photorealistic for Runware if not set
|
# Default to photorealistic for Runware if not set
|
||||||
if not image_style or image_style in ['natural', 'vivid']:
|
if not image_style or image_style in ['natural', 'vivid']:
|
||||||
@@ -1022,12 +923,12 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
|||||||
'provider': provider,
|
'provider': provider,
|
||||||
'model': model,
|
'model': model,
|
||||||
'image_type': image_style,
|
'image_type': image_style,
|
||||||
'available_styles': available_styles, # Loaded from GlobalIntegrationSettings model
|
'available_styles': available_styles,
|
||||||
'max_in_article_images': account_config.get('max_in_article_images') or global_settings.max_in_article_images,
|
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
|
||||||
'image_format': account_config.get('image_format', 'webp'),
|
'image_format': 'webp',
|
||||||
'desktop_enabled': account_config.get('desktop_enabled', True),
|
'desktop_enabled': True,
|
||||||
'featured_image_size': account_config.get('featured_image_size') or default_featured_size,
|
'featured_image_size': default_featured_size,
|
||||||
'desktop_image_size': account_config.get('desktop_image_size') or global_settings.desktop_image_size,
|
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
request=request
|
request=request
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
# ),
|
||||||
|
]
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
System module models - for global settings and prompts
|
System module models - for global settings and prompts
|
||||||
"""
|
"""
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
from igny8_core.auth.models import AccountBaseModel
|
from igny8_core.auth.models import AccountBaseModel
|
||||||
|
|
||||||
# Import settings models
|
# 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):
|
class AIPrompt(AccountBaseModel):
|
||||||
"""
|
"""
|
||||||
Account-specific AI Prompt templates.
|
Account-specific AI Prompt templates.
|
||||||
|
|||||||
@@ -17,11 +17,29 @@ class SystemSettingsAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(AccountSettings)
|
@admin.register(AccountSettings)
|
||||||
class AccountSettingsAdmin(AccountAdminMixin, ModelAdmin):
|
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']
|
search_fields = ['key', 'account__name']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
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):
|
def get_account_display(self, obj):
|
||||||
"""Safely get account name"""
|
"""Safely get account name"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from igny8_core.auth.models import AccountBaseModel
|
|||||||
|
|
||||||
class BaseSettings(AccountBaseModel):
|
class BaseSettings(AccountBaseModel):
|
||||||
"""Base class for all account-scoped settings models"""
|
"""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)
|
is_active = models.BooleanField(default=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -35,9 +34,39 @@ class SystemSettings(models.Model):
|
|||||||
return f"SystemSetting: {self.key}"
|
return f"SystemSetting: {self.key}"
|
||||||
|
|
||||||
|
|
||||||
class AccountSettings(BaseSettings):
|
class AccountSettings(AccountBaseModel):
|
||||||
"""Account-level settings"""
|
"""
|
||||||
key = models.CharField(max_length=255, db_index=True, help_text="Settings key identifier")
|
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:
|
class Meta:
|
||||||
db_table = 'igny8_account_settings'
|
db_table = 'igny8_account_settings'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Serializers for Settings Models
|
|||||||
"""
|
"""
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
|
from .ai_settings import SystemAISettings
|
||||||
from .validators import validate_settings_schema
|
from .validators import validate_settings_schema
|
||||||
|
|
||||||
|
|
||||||
@@ -71,3 +72,21 @@ class AISettingsSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
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)
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
|
|||||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
from .global_settings_models import GlobalModuleSettings
|
from .global_settings_models import GlobalModuleSettings
|
||||||
|
from .ai_settings import SystemAISettings
|
||||||
from .settings_serializers import (
|
from .settings_serializers import (
|
||||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||||
ModuleSettingsSerializer, AISettingsSerializer
|
ModuleSettingsSerializer, AISettingsSerializer, SystemAISettingsSerializer
|
||||||
)
|
)
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@@ -510,3 +514,184 @@ class AISettingsViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
serializer.save(account=account)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -790,7 +790,8 @@ UNFOLD = {
|
|||||||
"icon": "settings",
|
"icon": "settings",
|
||||||
"collapsible": True,
|
"collapsible": True,
|
||||||
"items": [
|
"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": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/globalmodulesettings/"},
|
||||||
{"title": "AI Prompts", "icon": "smart_toy", "link": lambda request: "/admin/system/globalaiprompt/"},
|
{"title": "AI Prompts", "icon": "smart_toy", "link": lambda request: "/admin/system/globalaiprompt/"},
|
||||||
{"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"},
|
{"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"},
|
||||||
|
|||||||
@@ -44,73 +44,59 @@ class AIProcessor:
|
|||||||
|
|
||||||
def __init__(self, account=None):
|
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:
|
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
|
self.account = account
|
||||||
|
|
||||||
# Get API keys from IntegrationSettings if account provided, else fallback to Django settings
|
# Get API keys from IntegrationProvider (centralized)
|
||||||
self.openai_api_key = self._get_api_key('openai', self.account)
|
self.openai_api_key = self._get_api_key('openai')
|
||||||
self.runware_api_key = self._get_api_key('runware', self.account)
|
self.runware_api_key = self._get_api_key('runware')
|
||||||
# Get model from IntegrationSettings if account provided, else fallback to Django settings
|
# Get model from AIModelConfig (is_default=True)
|
||||||
self.default_model = self._get_model('openai', self.account)
|
self.default_model = self._get_model('openai')
|
||||||
|
|
||||||
# Use global model rates
|
# Use global model rates
|
||||||
self.model_rates = MODEL_RATES
|
self.model_rates = MODEL_RATES
|
||||||
self.image_model_rates = IMAGE_MODEL_RATES
|
self.image_model_rates = IMAGE_MODEL_RATES
|
||||||
|
|
||||||
def _get_api_key(self, integration_type: str, account=None) -> Optional[str]:
|
def _get_api_key(self, integration_type: str) -> Optional[str]:
|
||||||
"""Get API key from IntegrationSettings or Django settings"""
|
"""Get API key from IntegrationProvider (centralized)"""
|
||||||
if account:
|
try:
|
||||||
try:
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
api_key = ModelRegistry.get_api_key(integration_type)
|
||||||
settings_obj = IntegrationSettings.objects.filter(
|
if api_key:
|
||||||
integration_type=integration_type,
|
logger.debug(f"Loaded {integration_type} API key from IntegrationProvider")
|
||||||
account=account,
|
return api_key
|
||||||
is_active=True
|
except Exception as e:
|
||||||
).first()
|
logger.warning(f"Could not load {integration_type} API key from IntegrationProvider: {e}")
|
||||||
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)
|
|
||||||
|
|
||||||
# Fallback to Django settings
|
# Fallback to Django settings (for backward compatibility)
|
||||||
if integration_type == 'openai':
|
if integration_type == 'openai':
|
||||||
return getattr(settings, 'OPENAI_API_KEY', None)
|
return getattr(settings, 'OPENAI_API_KEY', None)
|
||||||
elif integration_type == 'runware':
|
elif integration_type == 'runware':
|
||||||
return getattr(settings, 'RUNWARE_API_KEY', None)
|
return getattr(settings, 'RUNWARE_API_KEY', None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_model(self, integration_type: str, account=None) -> str:
|
def _get_model(self, integration_type: str) -> str:
|
||||||
"""Get model from IntegrationSettings or Django settings"""
|
"""Get default model from AIModelConfig (is_default=True)"""
|
||||||
if account and integration_type == 'openai':
|
try:
|
||||||
try:
|
from igny8_core.ai.model_registry import ModelRegistry
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
default_model = ModelRegistry.get_default_model('text')
|
||||||
settings_obj = IntegrationSettings.objects.filter(
|
if default_model:
|
||||||
integration_type=integration_type,
|
logger.debug(f"Using model '{default_model}' from AIModelConfig")
|
||||||
account=account,
|
return default_model
|
||||||
is_active=True
|
except Exception as e:
|
||||||
).first()
|
logger.warning(f"Could not load default model from AIModelConfig: {e}")
|
||||||
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)
|
|
||||||
|
|
||||||
# Fallback to Django settings or default
|
# Fallback to Django settings or default
|
||||||
default_model = getattr(settings, 'DEFAULT_AI_MODEL', 'gpt-4.1')
|
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
|
return default_model
|
||||||
|
|
||||||
def _call_openai(
|
def _call_openai(
|
||||||
|
|||||||
@@ -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
|
1. ✅ **Django Admin Cleanup** - DONE
|
||||||
2. ⏳ **Simplify AI Settings** - Merge content + image settings into AccountSettings
|
2. ⏳ **Simplify AI Settings** - Merge content + image settings into AccountSettings
|
||||||
3. ⏳ **Create IntegrationProvider** - Move API keys to dedicated model
|
3. ✅ **Create IntegrationProvider** - DONE (API keys now in dedicated model)
|
||||||
4. ⏳ **AIModelConfig Enhancement** - Add tokens_per_credit, credits_per_image, quality_tier
|
4. ✅ **AIModelConfig Enhancement** - DONE (tokens_per_credit, credits_per_image, quality_tier added)
|
||||||
|
|||||||
314
docs/plans/4th-jan-refactor/final-model-schemas.md
Normal file
314
docs/plans/4th-jan-refactor/final-model-schemas.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# Final Model Schemas - Clean State
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document defines the simplified, clean architecture for AI configuration and billing models.
|
||||||
|
|
||||||
|
**Total Models**: 5 (down from 7)
|
||||||
|
- IntegrationProvider (API keys)
|
||||||
|
- AIModelConfig (model definitions + pricing)
|
||||||
|
- AISettings (system defaults + account overrides) - *renamed from GlobalIntegrationSettings*
|
||||||
|
- AccountSettings (generic key-value store)
|
||||||
|
- CreditCostConfig (operation-level pricing)
|
||||||
|
|
||||||
|
**Models Removed**:
|
||||||
|
- IntegrationSettings (merged into AISettings/AccountSettings)
|
||||||
|
- CreditCostConfig.tokens_per_credit (moved to AIModelConfig)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. IntegrationProvider (API Keys Only)
|
||||||
|
|
||||||
|
Centralized storage for ALL external service API keys.
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `provider_id` | CharField(50) PK | Yes | openai, runware, stripe, paypal, resend |
|
||||||
|
| `display_name` | CharField(100) | Yes | Human-readable name |
|
||||||
|
| `provider_type` | CharField(20) | Yes | ai / payment / email / storage |
|
||||||
|
| `api_key` | CharField(500) | No | Primary API key |
|
||||||
|
| `api_secret` | CharField(500) | No | Secondary secret (Stripe, PayPal) |
|
||||||
|
| `webhook_secret` | CharField(500) | No | Webhook signing secret |
|
||||||
|
| `api_endpoint` | URLField | No | Custom endpoint (optional) |
|
||||||
|
| `config` | JSONField | No | Provider-specific config |
|
||||||
|
| `is_active` | BooleanField | Yes | Enable/disable provider |
|
||||||
|
| `is_sandbox` | BooleanField | Yes | Test mode flag |
|
||||||
|
| `updated_by` | FK(User) | No | Audit trail |
|
||||||
|
| `created_at` | DateTime | Auto | |
|
||||||
|
| `updated_at` | DateTime | Auto | |
|
||||||
|
|
||||||
|
**Seeded Providers**:
|
||||||
|
- `openai` - AI (text + DALL-E)
|
||||||
|
- `runware` - AI (images)
|
||||||
|
- `anthropic` - AI (future)
|
||||||
|
- `stripe` - Payment
|
||||||
|
- `paypal` - Payment
|
||||||
|
- `resend` - Email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AIModelConfig (Single Source of Truth for Models)
|
||||||
|
|
||||||
|
All AI models (text + image) with pricing and credit configuration.
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `id` | AutoField PK | Auto | |
|
||||||
|
| `model_name` | CharField(100) | Yes | gpt-5.1, dall-e-3, runware:97@1 |
|
||||||
|
| `model_type` | CharField(20) | Yes | text / image |
|
||||||
|
| `provider` | CharField(50) | Yes | Links to IntegrationProvider |
|
||||||
|
| `display_name` | CharField(200) | Yes | Human-readable |
|
||||||
|
| `is_default` | BooleanField | Yes | One default per type |
|
||||||
|
| `is_active` | BooleanField | Yes | Enable/disable |
|
||||||
|
| `cost_per_1k_input` | DecimalField | No | Provider cost (USD) - text models |
|
||||||
|
| `cost_per_1k_output` | DecimalField | No | Provider cost (USD) - text models |
|
||||||
|
| `tokens_per_credit` | IntegerField | No | Text: tokens per 1 credit (e.g., 1000) |
|
||||||
|
| `credits_per_image` | IntegerField | No | Image: credits per image (e.g., 1, 5, 15) |
|
||||||
|
| `quality_tier` | CharField(20) | No | basic / quality / premium |
|
||||||
|
| `max_tokens` | IntegerField | No | Model token limit |
|
||||||
|
| `context_window` | IntegerField | No | Model context size |
|
||||||
|
| `capabilities` | JSONField | No | vision, function_calling, etc. |
|
||||||
|
| `created_at` | DateTime | Auto | |
|
||||||
|
| `updated_at` | DateTime | Auto | |
|
||||||
|
|
||||||
|
**Credit Configuration Examples**:
|
||||||
|
|
||||||
|
| Model | Type | tokens_per_credit | credits_per_image | quality_tier |
|
||||||
|
|-------|------|-------------------|-------------------|--------------|
|
||||||
|
| gpt-5.1 | text | 1000 | - | - |
|
||||||
|
| gpt-4o-mini | text | 10000 | - | - |
|
||||||
|
| runware:97@1 | image | - | 1 | basic |
|
||||||
|
| dall-e-3 | image | - | 5 | quality |
|
||||||
|
| google:4@2 | image | - | 15 | premium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AISettings (Renamed from GlobalIntegrationSettings)
|
||||||
|
|
||||||
|
System-wide AI defaults. Singleton (pk=1).
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Notes |
|
||||||
|
|-------|------|----------|---------|-------|
|
||||||
|
| `id` | AutoField PK | Auto | 1 | Singleton |
|
||||||
|
| `temperature` | FloatField | Yes | 0.7 | AI temperature (0.0-2.0) |
|
||||||
|
| `max_tokens` | IntegerField | Yes | 8192 | Max response tokens |
|
||||||
|
| `image_style` | CharField(30) | Yes | photorealistic | Default image style |
|
||||||
|
| `image_quality` | CharField(20) | Yes | standard | standard / hd |
|
||||||
|
| `max_images_per_article` | IntegerField | Yes | 4 | Max in-article images |
|
||||||
|
| `image_size` | CharField(20) | Yes | 1024x1024 | Default image dimensions |
|
||||||
|
| `updated_by` | FK(User) | No | | Audit trail |
|
||||||
|
| `updated_at` | DateTime | Auto | | |
|
||||||
|
|
||||||
|
**Removed Fields** (now elsewhere):
|
||||||
|
- All `*_api_key` fields → IntegrationProvider
|
||||||
|
- All `*_model` fields → AIModelConfig.is_default
|
||||||
|
- `default_text_provider` → AIModelConfig.is_default where model_type='text'
|
||||||
|
- `default_image_service` → AIModelConfig.is_default where model_type='image'
|
||||||
|
|
||||||
|
**Image Style Choices**:
|
||||||
|
- `photorealistic` - Ultra realistic photography
|
||||||
|
- `illustration` - Digital illustration
|
||||||
|
- `3d_render` - Computer generated 3D
|
||||||
|
- `minimal_flat` - Minimal / Flat Design
|
||||||
|
- `artistic` - Artistic / Painterly
|
||||||
|
- `cartoon` - Cartoon / Stylized
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AccountSettings (Per-Account Overrides)
|
||||||
|
|
||||||
|
Generic key-value store for account-specific settings.
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `id` | AutoField PK | Auto | |
|
||||||
|
| `account` | FK(Account) | Yes | |
|
||||||
|
| `key` | CharField(100) | Yes | Setting key |
|
||||||
|
| `value` | JSONField | Yes | Setting value |
|
||||||
|
| `created_at` | DateTime | Auto | |
|
||||||
|
| `updated_at` | DateTime | Auto | |
|
||||||
|
|
||||||
|
**Unique Constraint**: `(account, key)`
|
||||||
|
|
||||||
|
**AI-Related Keys** (override AISettings defaults):
|
||||||
|
|
||||||
|
| Key | Type | Example | Notes |
|
||||||
|
|-----|------|---------|-------|
|
||||||
|
| `ai.temperature` | float | 0.7 | Override system default |
|
||||||
|
| `ai.max_tokens` | int | 8192 | Override system default |
|
||||||
|
| `ai.image_style` | string | "photorealistic" | Override system default |
|
||||||
|
| `ai.image_quality` | string | "hd" | Override system default |
|
||||||
|
| `ai.max_images` | int | 6 | Override system default |
|
||||||
|
| `ai.image_quality_tier` | string | "quality" | User's preferred tier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CreditCostConfig (Operation-Level Pricing)
|
||||||
|
|
||||||
|
Fixed credit costs per operation type.
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `operation_type` | CharField(50) PK | Yes | Unique operation ID |
|
||||||
|
| `display_name` | CharField(100) | Yes | Human-readable |
|
||||||
|
| `base_credits` | IntegerField | Yes | Fixed credits per operation |
|
||||||
|
| `is_active` | BooleanField | Yes | Enable/disable |
|
||||||
|
| `description` | TextField | No | Admin notes |
|
||||||
|
|
||||||
|
**Removed**: `tokens_per_credit` (now in AIModelConfig)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Settings Structure
|
||||||
|
|
||||||
|
### Content Generation Settings Tab
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Content Generation Settings │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ AI Parameters │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Temperature [=====○====] 0.7 │ │
|
||||||
|
│ │ More focused ←→ More creative │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Max Tokens [8192 ▼] │ │
|
||||||
|
│ │ Response length limit │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Image Generation │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Image Quality │ │
|
||||||
|
│ │ ○ Basic (1 credit/image) - Fast, simple │ │
|
||||||
|
│ │ ● Quality (5 credits/image) - Balanced │ │
|
||||||
|
│ │ ○ Premium (15 credits/image) - Best quality │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Image Style [Photorealistic ▼] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Images per Article [4 ▼] (max 8) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Save Settings] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend API Response
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/accounts/settings/ai/
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"content_generation": {
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 8192
|
||||||
|
},
|
||||||
|
"image_generation": {
|
||||||
|
"quality_tiers": [
|
||||||
|
{"tier": "basic", "credits": 1, "label": "Basic", "description": "Fast, simple images"},
|
||||||
|
{"tier": "quality", "credits": 5, "label": "Quality", "description": "Balanced quality"},
|
||||||
|
{"tier": "premium", "credits": 15, "label": "Premium", "description": "Best quality"}
|
||||||
|
],
|
||||||
|
"selected_tier": "quality",
|
||||||
|
"styles": [
|
||||||
|
{"value": "photorealistic", "label": "Photorealistic"},
|
||||||
|
{"value": "illustration", "label": "Illustration"},
|
||||||
|
{"value": "3d_render", "label": "3D Render"},
|
||||||
|
{"value": "minimal_flat", "label": "Minimal / Flat"},
|
||||||
|
{"value": "artistic", "label": "Artistic"},
|
||||||
|
{"value": "cartoon", "label": "Cartoon"}
|
||||||
|
],
|
||||||
|
"selected_style": "photorealistic",
|
||||||
|
"max_images": 4,
|
||||||
|
"max_allowed": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Settings Request
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/v1/accounts/settings/ai/
|
||||||
|
|
||||||
|
Request:
|
||||||
|
{
|
||||||
|
"temperature": 0.8,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"image_quality_tier": "premium",
|
||||||
|
"image_style": "illustration",
|
||||||
|
"max_images": 6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ IntegrationProv. │ │ AIModelConfig │ │ AISettings │
|
||||||
|
│ │ │ │ │ (Singleton) │
|
||||||
|
│ - API keys │◄────│ - Model list │ │ │
|
||||||
|
│ - Provider info │ │ - Pricing │ │ - Defaults │
|
||||||
|
└──────────────────┘ │ - Credits config │ │ - temperature │
|
||||||
|
│ - quality_tier │ │ - max_tokens │
|
||||||
|
└────────┬─────────┘ │ - image_style │
|
||||||
|
│ └────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ AccountSettings │
|
||||||
|
│ │
|
||||||
|
│ Account-specific overrides: │
|
||||||
|
│ - ai.temperature = 0.8 │
|
||||||
|
│ - ai.image_quality_tier = "premium" │
|
||||||
|
│ - ai.image_style = "illustration" │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Frontend Settings UI │
|
||||||
|
│ │
|
||||||
|
│ GET /api/v1/accounts/settings/ai/ │
|
||||||
|
│ - Merges AISettings + AccountSettings │
|
||||||
|
│ - Returns effective values for account │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Summary
|
||||||
|
|
||||||
|
| From | To | Action |
|
||||||
|
|------|----|--------|
|
||||||
|
| GlobalIntegrationSettings.*_api_key | IntegrationProvider | Already done |
|
||||||
|
| GlobalIntegrationSettings.*_model | AIModelConfig.is_default | Already done |
|
||||||
|
| GlobalIntegrationSettings (remaining) | AISettings (rename) | Phase 3 |
|
||||||
|
| IntegrationSettings | AccountSettings | Phase 3 - delete model |
|
||||||
|
| CreditCostConfig.tokens_per_credit | AIModelConfig.tokens_per_credit | Already done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 Implementation Steps
|
||||||
|
|
||||||
|
1. **Rename Model**: GlobalIntegrationSettings → AISettings
|
||||||
|
2. **Remove Fields**: All `*_api_key`, `*_model` fields from AISettings
|
||||||
|
3. **Create API Endpoint**: `/api/v1/accounts/settings/ai/`
|
||||||
|
4. **Update Frontend**: Load from new endpoint, use quality_tier picker
|
||||||
|
5. **Delete Model**: IntegrationSettings (after migrating any data)
|
||||||
|
6. **Cleanup**: Remove deprecated choices/constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify (Phase 3)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `modules/system/global_settings_models.py` | Rename class, remove deprecated fields |
|
||||||
|
| `modules/system/admin.py` | Update admin for AISettings |
|
||||||
|
| `modules/system/views.py` | New AI settings API endpoint |
|
||||||
|
| `modules/system/serializers.py` | AISettingsSerializer |
|
||||||
|
| `settings.py` | Update admin sidebar |
|
||||||
|
| `ai/ai_core.py` | Use AISettings instead of GlobalIntegrationSettings |
|
||||||
|
| Frontend | New settings component with quality tier picker |
|
||||||
@@ -88,7 +88,6 @@ const Users = lazy(() => import("./pages/Settings/Users"));
|
|||||||
const Subscriptions = lazy(() => import("./pages/Settings/Subscriptions"));
|
const Subscriptions = lazy(() => import("./pages/Settings/Subscriptions"));
|
||||||
const SystemSettings = lazy(() => import("./pages/Settings/System"));
|
const SystemSettings = lazy(() => import("./pages/Settings/System"));
|
||||||
const AccountSettings = lazy(() => import("./pages/Settings/Account"));
|
const AccountSettings = lazy(() => import("./pages/Settings/Account"));
|
||||||
const AISettings = lazy(() => import("./pages/Settings/AI"));
|
|
||||||
const Plans = lazy(() => import("./pages/Settings/Plans"));
|
const Plans = lazy(() => import("./pages/Settings/Plans"));
|
||||||
const Industries = lazy(() => import("./pages/Settings/Industries"));
|
const Industries = lazy(() => import("./pages/Settings/Industries"));
|
||||||
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
||||||
@@ -258,7 +257,6 @@ export default function App() {
|
|||||||
<Route path="/settings/subscriptions" element={<Subscriptions />} />
|
<Route path="/settings/subscriptions" element={<Subscriptions />} />
|
||||||
<Route path="/settings/system" element={<SystemSettings />} />
|
<Route path="/settings/system" element={<SystemSettings />} />
|
||||||
<Route path="/settings/account" element={<AccountSettings />} />
|
<Route path="/settings/account" element={<AccountSettings />} />
|
||||||
<Route path="/settings/ai" element={<AISettings />} />
|
|
||||||
<Route path="/settings/plans" element={<Plans />} />
|
<Route path="/settings/plans" element={<Plans />} />
|
||||||
<Route path="/settings/industries" element={<Industries />} />
|
<Route path="/settings/industries" element={<Industries />} />
|
||||||
{/* AI Models Settings - Admin Only */}
|
{/* AI Models Settings - Admin Only */}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
||||||
import { fetchAPI } from '../../services/api';
|
|
||||||
import { Card } from '../../components/ui/card';
|
|
||||||
|
|
||||||
export default function AISettings() {
|
|
||||||
const toast = useToast();
|
|
||||||
const [settings, setSettings] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSettings();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadSettings = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await fetchAPI('/v1/system/settings/ai/');
|
|
||||||
setSettings(response.results || []);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to load AI settings: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<PageMeta title="AI Settings" />
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">AI Settings</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Configure AI models and writing preferences</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-gray-500">Loading...</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card className="p-6">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">AI settings management interface coming soon.</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user