diff --git a/cookbook/migrations/0223_space_ai_credits_balance_space_ai_credits_monthly_and_more.py b/cookbook/migrations/0223_space_ai_credits_balance_space_ai_credits_monthly_and_more.py new file mode 100644 index 000000000..8e91ea06f --- /dev/null +++ b/cookbook/migrations/0223_space_ai_credits_balance_space_ai_credits_monthly_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.22 on 2025-09-05 06:51 + +import cookbook.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0222_alter_shoppinglistrecipe_created_by_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='space', + name='ai_credits_balance', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='space', + name='ai_credits_monthly', + field=models.IntegerField(default=0), + ), + migrations.CreateModel( + name='AiProvider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128)), + ('description', models.TextField(blank=True)), + ('api_key', models.CharField(max_length=2048)), + ('model_name', models.CharField(max_length=256)), + ('url', models.CharField(blank=True, max_length=2048, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('space', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')), + ], + ), + migrations.CreateModel( + name='AiLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('function', models.CharField(max_length=64)), + ('credit_cost', models.DecimalField(decimal_places=4, max_digits=16)), + ('credits_from_balance', models.BooleanField(default=False)), + ('input_tokens', models.IntegerField(default=0)), + ('output_tokens', models.IntegerField(default=0)), + ('start_time', models.DateTimeField(null=True)), + ('end_time', models.DateTimeField(null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('ai_provider', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.aiprovider')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')), + ], + bases=(models.Model, cookbook.models.PermissionModelMixin), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index cd080d653..ae06a1234 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -329,6 +329,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model): demo = models.BooleanField(default=False) food_inherit = models.ManyToManyField(FoodInheritField, blank=True) + ai_credits_monthly = models.IntegerField(default=0) + ai_credits_balance = models.IntegerField(default=0) + internal_note = models.TextField(blank=True, null=True) def safe_delete(self): @@ -393,6 +396,38 @@ class Space(ExportModelOperationsMixin('space'), models.Model): return self.name +class AiProvider(models.Model): + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + # AiProviders can be global, so space=null is allowed (configurable by superusers) + space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) + + api_key = models.CharField(max_length=2048) + model_name = models.CharField(max_length=256) + url = models.CharField(max_length=2048, blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class AiLog(models.Model, PermissionModelMixin): + ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True) + function = models.CharField(max_length=64) + credit_cost = models.DecimalField(max_digits=16, decimal_places=4) + # if credits from balance were used, else its from monthly quota + credits_from_balance = models.BooleanField(default=False) + + input_tokens = models.IntegerField(default=0) + output_tokens = models.IntegerField(default=0) + start_time = models.DateTimeField(null=True) + end_time = models.DateTimeField(null=True) + + space = models.ForeignKey(Space, on_delete=models.CASCADE) + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class ConnectorConfig(models.Model, PermissionModelMixin): HOMEASSISTANT = 'HomeAssistant' CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index d8ddf0b00..d3a9393a8 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -36,7 +36,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu ShareLink, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, - UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig, SearchPreference, SearchFields) + UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig, SearchPreference, SearchFields, AiLog) from cookbook.templatetags.custom_tags import markdown from recipes.settings import AWS_ENABLED, MEDIA_URL, EMAIL_HOST @@ -330,6 +330,7 @@ class SpaceSerializer(WritableNestedModelSerializer): user_count = serializers.SerializerMethodField('get_user_count') recipe_count = serializers.SerializerMethodField('get_recipe_count') file_size_mb = serializers.SerializerMethodField('get_file_size_mb') + ai_monthly_credits_used = serializers.SerializerMethodField('get_ai_monthly_credits_used') food_inherit = FoodInheritFieldSerializer(many=True) image = UserFileViewSerializer(required=False, many=False, allow_null=True) nav_logo = UserFileViewSerializer(required=False, many=False, allow_null=True) @@ -350,6 +351,10 @@ class SpaceSerializer(WritableNestedModelSerializer): def get_recipe_count(self, obj): return Recipe.objects.filter(space=obj).count() + @extend_schema_field(int) + def get_ai_monthly_credits_used(self, obj): + return AiLog.objects.filter(space=obj, credits_from_balance=False).aggregate(Sum('credit_cost'))['credit_cost__sum'] + @extend_schema_field(float) def get_file_size_mb(self, obj): try: @@ -366,10 +371,11 @@ class SpaceSerializer(WritableNestedModelSerializer): 'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb', 'image', 'nav_logo', 'space_theme', 'custom_space_theme', 'nav_bg_color', 'nav_text_color', - 'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg',) + 'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg', 'ai_credits_monthly', + 'ai_credits_balance', 'ai_monthly_credits_used') read_only_fields = ( 'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', - 'demo',) + 'demo', 'ai_credits_monthly', 'ai_credits_balance', 'ai_monthly_credits_used') class UserSpaceSerializer(WritableNestedModelSerializer): @@ -1038,7 +1044,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer): fields = ( 'id', 'name', 'description', 'image', 'keywords', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', - 'internal', 'private','servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent' + 'internal', 'private', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent' ) # TODO having these readonly fields makes "RecipeOverview.ts" (API Client) not generate the RecipeOverviewToJSON second else block which leads to errors when using the api # TODO find a solution (custom schema?) to have these fields readonly (to save performance) and generate a proper client (two serializers would probably do the trick) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index f1f196da4..64eef47d4 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -2000,6 +2000,17 @@ class AiImportView(APIView): if serializer.is_valid(): # TODO max file size check + def log_ai_request(kwargs, completion_response, start_time, end_time): + print(completion_response['usage']['completion_tokens'], completion_response['usage']['prompt_tokens'], start_time, end_time) + try: + response_cost = kwargs.get("response_cost", 0) + print("response_cost", response_cost) + except: + print('could not get cost') + traceback.print_exc() + + litellm.success_callback = [log_ai_request] + messages = [] uploaded_file = serializer.validated_data['file']