mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-24 02:39:20 -05:00
lots of AI provider stuff
This commit is contained in:
@@ -8,7 +8,10 @@ def get_monthly_token_usage(space):
|
||||
"""
|
||||
returns the number of credits the space has used in the current month
|
||||
"""
|
||||
return AiLog.objects.filter(space=space, credits_from_balance=False, created_at__month=timezone.now().month).aggregate(Sum('credit_cost'))['credit_cost__sum']
|
||||
token_usage = AiLog.objects.filter(space=space, credits_from_balance=False, created_at__month=timezone.now().month).aggregate(Sum('credit_cost'))['credit_cost__sum']
|
||||
if token_usage is None:
|
||||
token_usage = 0
|
||||
return token_usage
|
||||
|
||||
|
||||
def has_monthly_token(space):
|
||||
@@ -16,3 +19,7 @@ def has_monthly_token(space):
|
||||
checks if the monthly credit limit has been exceeded
|
||||
"""
|
||||
return get_monthly_token_usage(space) < space.ai_credits_monthly
|
||||
|
||||
|
||||
def can_perform_ai_request(space):
|
||||
return has_monthly_token(space) and space.ai_enabled
|
||||
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_credits_monthly',
|
||||
field=models.IntegerField(default=0),
|
||||
field=models.IntegerField(default=100),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AiProvider',
|
||||
|
||||
@@ -345,6 +345,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
BookmarkletImport.objects.filter(space=self).delete()
|
||||
CustomFilter.objects.filter(space=self).delete()
|
||||
|
||||
AiLog.objects.filter(space=self).delete()
|
||||
AiProvider.objects.filter(space=self).delete()
|
||||
|
||||
Property.objects.filter(space=self).delete()
|
||||
PropertyType.objects.filter(space=self).delete()
|
||||
|
||||
|
||||
@@ -404,21 +404,33 @@ class SpacedModelSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class AiProviderSerializer(serializers.ModelSerializer):
|
||||
api_key = serializers.CharField(required=False, write_only=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
if not self.context['request'].user.is_superuser:
|
||||
validated_data['space'] = self.context['request'].space
|
||||
validated_data = self.handle_global_space_logic(validated_data)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self.handle_global_space_logic(validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def handle_global_space_logic(self, validated_data):
|
||||
"""
|
||||
allow superusers to create AI providers without a space but make sure everyone else only uses their own space
|
||||
"""
|
||||
if ('space' not in validated_data or not validated_data['space']) and self.context['request'].user.is_superuser:
|
||||
validated_data['space'] = None
|
||||
else:
|
||||
validated_data['space'] = self.context['request'].space
|
||||
|
||||
return validated_data
|
||||
|
||||
class Meta:
|
||||
model = AiProvider
|
||||
fields = ('id', 'name', 'description', 'api_key', 'model_name', 'url', 'log_credit_cost', 'space', 'created_at', 'updated_at')
|
||||
read_only_fields = ('created_at', 'updated_at',)
|
||||
|
||||
extra_kwargs = {
|
||||
'api_key': {'write_only': True},
|
||||
}
|
||||
|
||||
|
||||
class AiLogSerializer(serializers.ModelSerializer):
|
||||
ai_provider = AiProviderSerializer(read_only=True)
|
||||
|
||||
@@ -65,7 +65,7 @@ from cookbook.connectors.connector_manager import ConnectorManager, ActionType
|
||||
from cookbook.forms import ImportForm, ImportExportBase
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.HelperFunctions import str2bool, validate_import_url
|
||||
from cookbook.helper.ai_helper import has_monthly_token
|
||||
from cookbook.helper.ai_helper import has_monthly_token, can_perform_ai_request
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.open_data_importer import OpenDataImporter
|
||||
@@ -2032,10 +2032,10 @@ class AiImportView(APIView):
|
||||
}
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not has_monthly_token(request.space):
|
||||
if not can_perform_ai_request(request.space):
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _("You don't have any credits remaining to use AI."),
|
||||
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
|
||||
}
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -2129,9 +2129,15 @@ class AiImportView(APIView):
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
ai_response = completion(api_key=ai_provider.api_key,
|
||||
model=ai_provider.model_name,
|
||||
response_format={"type": "json_object"}, messages=messages, )
|
||||
ai_request = {
|
||||
'api_key': ai_provider.api_key,
|
||||
'model': ai_provider.model_name,
|
||||
'response_format': {"type": "json_object"},
|
||||
'messages': messages,
|
||||
}
|
||||
if ai_provider.url:
|
||||
ai_request['api_base'] = ai_provider.url
|
||||
ai_response = completion(**ai_request)
|
||||
except BadRequestError as err:
|
||||
response = {
|
||||
'error': True,
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
<template v-if="hasMoreItems && !loading" #afterlist>
|
||||
<span class="text-disabled font-italic text-caption ms-3">{{ $t('ModelSelectResultsHelp') }}</span>
|
||||
</template>
|
||||
|
||||
</Multiselect>
|
||||
|
||||
<template #append v-if="$slots.append">
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
|
||||
<v-textarea :label="$t('Description')" v-model="editingObj.description"></v-textarea>
|
||||
|
||||
<v-checkbox :label="$t('Global')" :hint="$t('GlobalHelp')" v-model="globalProvider" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint class="mb-2"></v-checkbox>
|
||||
|
||||
<v-text-field :label="$t('APIKey')" v-model="editingObj.apiKey"></v-text-field>
|
||||
|
||||
@@ -22,11 +21,15 @@
|
||||
|
||||
</v-combobox>
|
||||
|
||||
<p class="mt-2 mb-2">{{$t('AiModelHelp')}} <a href="https://docs.litellm.ai/docs/providers" target="_blank">LiteLLM</a></p>
|
||||
<p class="mt-2 mb-2">{{ $t('AiModelHelp') }} <a href="https://docs.litellm.ai/docs/providers" target="_blank">LiteLLM</a></p>
|
||||
|
||||
<v-checkbox :label="$t('LogCredits')" :hint="$t('LogCreditsHelp')" v-model="globalProvider" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint class="mb-2"></v-checkbox>
|
||||
<v-checkbox :label="$t('LogCredits')" :hint="$t('LogCreditsHelp')" v-model="editingObj.logCreditCost" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint
|
||||
class="mb-2"></v-checkbox>
|
||||
<v-text-field :label="$t('Url')" v-model="editingObj.url"></v-text-field>
|
||||
|
||||
<v-checkbox :label="$t('Global')" :hint="$t('GlobalHelp')" v-model="globalProvider" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint
|
||||
class="mb-2"></v-checkbox>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</model-editor-base>
|
||||
@@ -41,6 +44,7 @@ import {AiProvider} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import editor from "mavon-editor";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
@@ -66,8 +70,8 @@ const aiModels = ref(['gemini/gemini-2.5-pro', 'gemini/gemini-2.5-flash', 'gemin
|
||||
|
||||
const globalProvider = ref(false)
|
||||
|
||||
watch(() => globalProvider, () => {
|
||||
if(globalProvider.value){
|
||||
watch(() => globalProvider.value, () => {
|
||||
if (globalProvider.value) {
|
||||
editingObj.value.space = undefined
|
||||
} else {
|
||||
editingObj.value.space = useUserPreferenceStore().activeSpace.id!
|
||||
@@ -83,7 +87,11 @@ onMounted(() => {
|
||||
*/
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
itemDefaults: props.itemDefaults
|
||||
itemDefaults: props.itemDefaults,
|
||||
newItemFunction: () => {
|
||||
editingObj.value.logCreditCost = true
|
||||
editingObj.value.space = useUserPreferenceStore().activeSpace.id!
|
||||
},
|
||||
}).then(() => {
|
||||
globalProvider.value = editingObj.value.space == undefined
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<v-date-input :label="$t('Valid Until')" v-model="editingObj.validUntil"></v-date-input>
|
||||
<v-textarea :label="$t('Note')" v-model="editingObj.internalNote"></v-textarea>
|
||||
<v-checkbox :label="$t('Reusable')" v-model="editingObj.reusable"></v-checkbox>
|
||||
<v-text-field :label="$t('Link')" readonly :model-value="inviteLinkUrl(editingObj)">
|
||||
<v-text-field :label="$t('Link')" readonly :model-value="inviteLinkUrl(editingObj)" v-if="!isUpdate">
|
||||
<template #append-inner>
|
||||
<btn-copy variant="plain" color="undefined" :copy-value="inviteLinkUrl(editingObj)"></btn-copy>
|
||||
</template>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<v-text-field :label="$t('Username')" v-model="editingObj.username" v-if="editingObj.method == 'NEXTCLOUD' || editingObj.method == 'DB'"></v-text-field>
|
||||
|
||||
<v-text-field :label="$t('Password')" :hint="$t('StoragePasswordTokenHelp')" persistent-hint v-model="editingObj.password" v-if="editingObj.method == 'NEXTCLOUD'"></v-text-field>
|
||||
<v-text-field :label="$t('Access_Token')" :hint="$t('StoragePasswordTokenHelp')" persistent-hint v-model="editingObj.token" v-if="editingObj.method == 'DB'"></v-text-field>
|
||||
<v-text-field :label="$t('Access_Token')" :hint="$t('StoragePasswordTokenHelp')" persistent-hint v-model="editingObj.token" v-if="editingObj.method == 'DB'"></v-text-field>
|
||||
|
||||
<v-text-field :label="$t('Path')" v-model="editingObj.path"></v-text-field>
|
||||
<v-text-field :label="$t('Path')" v-model="editingObj.path"></v-text-field>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
@@ -33,7 +33,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, watch} from "vue";
|
||||
import { Storage } from "@/openapi";
|
||||
import {Storage} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
@@ -64,7 +64,7 @@ onMounted(() => {
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor(){
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
|
||||
}
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
"FuzzySearchHelp": "Verwende unscharfe Suche um Einträge auch bei Unterschieden in der Schreibweise zu finden.",
|
||||
"GettingStarted": "Erste Schritte",
|
||||
"Global": "Global",
|
||||
"GlobalHelp": "Globale AI Anbieter können von Nutzern aller Spaces verwendet werden. ",
|
||||
"GlobalHelp": "Globale AI Anbieter können von Nutzern aller Spaces verwendet werden. Sie können nur dich Instanz Admins (Superusers) erstellt und bearbeitet werden.",
|
||||
"GroupBy": "Gruppieren nach",
|
||||
"HeaderWarning": "Achtung: Durch ändern auf Überschrift werden Menge/Einheit/Lebensmittel gelöscht",
|
||||
"Headline": "Überschrift",
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
"FuzzySearchHelp": "Use fuzzy search to find entries even when there are differences in how the word is written.",
|
||||
"GettingStarted": "Getting Started",
|
||||
"Global": "Global",
|
||||
"GlobalHelp": "Global AI Providers can be used by users of all spaces. ",
|
||||
"GlobalHelp": "Global AI Providers can be used by users of all spaces. They can only be created and edited by superusers. ",
|
||||
"GroupBy": "Group By",
|
||||
"HeaderWarning": "Warning: Changing to a Heading deletes the Amount/Unit/Food",
|
||||
"Headline": "Headline",
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface AiProvider {
|
||||
* @type {string}
|
||||
* @memberof AiProvider
|
||||
*/
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -86,7 +86,6 @@ export interface AiProvider {
|
||||
*/
|
||||
export function instanceOfAiProvider(value: object): value is AiProvider {
|
||||
if (!('name' in value) || value['name'] === undefined) return false;
|
||||
if (!('apiKey' in value) || value['apiKey'] === undefined) return false;
|
||||
if (!('modelName' in value) || value['modelName'] === undefined) return false;
|
||||
if (!('createdAt' in value) || value['createdAt'] === undefined) return false;
|
||||
if (!('updatedAt' in value) || value['updatedAt'] === undefined) return false;
|
||||
@@ -106,7 +105,7 @@ export function AiProviderFromJSONTyped(json: any, ignoreDiscriminator: boolean)
|
||||
'id': json['id'] == null ? undefined : json['id'],
|
||||
'name': json['name'],
|
||||
'description': json['description'] == null ? undefined : json['description'],
|
||||
'apiKey': json['api_key'],
|
||||
'apiKey': json['api_key'] == null ? undefined : json['api_key'],
|
||||
'modelName': json['model_name'],
|
||||
'url': json['url'] == null ? undefined : json['url'],
|
||||
'logCreditCost': json['log_credit_cost'] == null ? undefined : json['log_credit_cost'],
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:item.space="{ item }" v-if="genericModel.model.name == 'AiProvider'">
|
||||
<v-chip label v-if="item.space == null" color="success">{{$t('Global')}}</v-chip>
|
||||
<v-chip label v-else color="info">{{$t('Space')}}</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.action="{ item }">
|
||||
<v-btn class="float-right" icon="$menu" variant="plain">
|
||||
<v-icon icon="$menu"></v-icon>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6" v-if="useUserPreferenceStore().serverSettings.enableAiImport">
|
||||
<v-col cols="12" md="6" v-if="useUserPreferenceStore().activeSpace.aiEnabled">
|
||||
<v-card
|
||||
:title="$t('AI')"
|
||||
:subtitle="$t('AIImportSubtitle')"
|
||||
@@ -69,7 +69,7 @@
|
||||
:color="(importType == 'ai') ? 'primary' : ''"
|
||||
elevation="1"
|
||||
@click="importType = 'ai'"
|
||||
:disabled="!useUserPreferenceStore().serverSettings.enableAiImport">
|
||||
:disabled="!useUserPreferenceStore().activeSpace.aiEnabled">
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
@@ -142,7 +142,11 @@
|
||||
<div v-if="importType == 'ai'">
|
||||
<v-row>
|
||||
<v-col md="6">
|
||||
<ModelSelect model="AiProvider" v-model="selectedAiProvider"></ModelSelect>
|
||||
<ModelSelect model="AiProvider" v-model="selectedAiProvider">
|
||||
<template #append>
|
||||
<v-btn icon="$settings" :to="{name:'ModelListPage', params: {model: 'AiProvider'}}" color="success"></v-btn>
|
||||
</template>
|
||||
</ModelSelect>
|
||||
</v-col>
|
||||
<v-col md="6">
|
||||
<v-btn-toggle class="mb-2" border divided v-model="aiMode">
|
||||
@@ -735,12 +739,12 @@ function loadRecipeFromUrl(recipeFromSourceRequest: RecipeFromSource) {
|
||||
function loadRecipeFromAiImport() {
|
||||
let request = null
|
||||
|
||||
if(selectedAiProvider.value == undefined) {
|
||||
if (selectedAiProvider.value == undefined) {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, "No AI Provider selected")
|
||||
}
|
||||
|
||||
if (image.value != null && aiMode.value == 'file') {
|
||||
request = doAiImport(selectedAiProvider.value.id!,image.value)
|
||||
request = doAiImport(selectedAiProvider.value.id!, image.value)
|
||||
} else if (sourceImportText.value != '' && aiMode.value == 'text') {
|
||||
request = doAiImport(selectedAiProvider.value.id!, null, sourceImportText.value)
|
||||
}
|
||||
|
||||
@@ -810,7 +810,7 @@ export const TAiProvider = {
|
||||
|
||||
tableHeaders: [
|
||||
{title: 'Name', key: 'name'},
|
||||
|
||||
{title: 'Global', key: 'space'},
|
||||
{title: 'Actions', key: 'action', align: 'end'},
|
||||
]
|
||||
} as Model
|
||||
|
||||
Reference in New Issue
Block a user