lots of AI provider stuff

This commit is contained in:
vabene1111
2025-09-09 07:54:59 +02:00
parent 98d308aee9
commit 286d707347
15 changed files with 79 additions and 37 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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()

View File

@@ -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)

View File

@@ -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,

View File

@@ -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">

View File

@@ -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
})

View File

@@ -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>

View File

@@ -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})
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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'],

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -810,7 +810,7 @@ export const TAiProvider = {
tableHeaders: [
{title: 'Name', key: 'name'},
{title: 'Global', key: 'space'},
{title: 'Actions', key: 'action', align: 'end'},
]
} as Model