feat: OpenAI Custom Headers/Params and Debug Page (#4227)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-09-23 04:04:36 -05:00 committed by GitHub
parent 7c274de778
commit ea1f727a8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 277 additions and 17 deletions

View File

@ -105,18 +105,21 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
:octicons-tag-24: v1.7.0
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
| Variables | Default | Description |
| ---------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
### Themeing
### Theming
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.

File diff suppressed because one or more lines are too long

View File

@ -84,13 +84,12 @@
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to">
<v-list-item v-for="child in nav.children" :key="child.key || child.title" exact :to="child.to" class="ml-2">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
<v-divider class="mb-4"></v-divider>
</v-list-group>
<!-- Single Item -->

View File

@ -1246,7 +1246,11 @@
"here-are-a-few-things-to-help-you-get-started": "Here are a few things to help you get started with Mealie",
"restore-from-v1-backup": "Have a backup from a previous instance of Mealie v1? You can restore it here.",
"manage-profile-or-get-invite-link": "Manage your own profile, or grab an invite link to share with others."
}
},
"debug-openai-services": "Debug OpenAI Services",
"debug-openai-services-description": "Use this page to debug OpenAI services. You can test your OpenAI connection and see the results here. If you have image services enabled, you can also provide an image.",
"run-test": "Run Test",
"test-results": "Test Results"
},
"profile": {
"welcome-user": "👋 Welcome, {0}!",

View File

@ -91,12 +91,25 @@ export default defineComponent({
title: i18n.tc("sidebar.maintenance"),
restricted: true,
},
{
icon: $globals.icons.robot,
title: i18n.tc("recipe.debug"),
restricted: true,
children: [
{
icon: $globals.icons.robot,
to: "/admin/debug/openai",
title: i18n.tc("admin.openai"),
restricted: true,
},
{
icon: $globals.icons.slotMachine,
to: "/admin/parser",
to: "/admin/debug/parser",
title: i18n.tc("sidebar.parser"),
restricted: true,
},
]
},
];
const bottomLinks: SidebarLinks = [

View File

@ -0,0 +1,21 @@
import { BaseAPI } from "../base/base-clients";
import { DebugResponse } from "~/lib/api/types/admin";
const prefix = "/api";
const routes = {
openai: `${prefix}/admin/debug/openai`,
};
export class AdminDebugAPI extends BaseAPI {
async debugOpenAI(fileObject: Blob | File | undefined = undefined, fileName = "") {
let formData: FormData | null = null;
if (fileObject) {
formData = new FormData();
formData.append("image", fileObject);
formData.append("extension", fileName.split(".").pop() ?? "");
}
return await this.requests.post<DebugResponse>(routes.openai, formData);
}
}

View File

@ -5,6 +5,7 @@ import { AdminGroupsApi } from "./admin/admin-groups";
import { AdminBackupsApi } from "./admin/admin-backups";
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
import { AdminAnalyticsApi } from "./admin/admin-analytics";
import { AdminDebugAPI } from "./admin/admin-debug";
import { ApiRequestInstance } from "~/lib/api/types/non-generated";
export class AdminAPI {
@ -15,6 +16,7 @@ export class AdminAPI {
public backups: AdminBackupsApi;
public maintenance: AdminMaintenanceApi;
public analytics: AdminAnalyticsApi;
public debug: AdminDebugAPI;
constructor(requests: ApiRequestInstance) {
this.about = new AdminAboutAPI(requests);
@ -24,6 +26,7 @@ export class AdminAPI {
this.backups = new AdminBackupsApi(requests);
this.maintenance = new AdminMaintenanceApi(requests);
this.analytics = new AdminAnalyticsApi(requests);
this.debug = new AdminDebugAPI(requests);
Object.freeze(this);
}

View File

@ -173,6 +173,10 @@ export interface CustomPageOut {
categories?: RecipeCategoryResponse[];
id: number;
}
export interface DebugResponse {
success: boolean;
response?: string | null;
}
export interface EmailReady {
ready: boolean;
}

View File

@ -0,0 +1,127 @@
<template>
<v-container class="pa-0">
<v-container>
<BaseCardSectionTitle :title="$tc('admin.debug-openai-services')">
{{ $t('admin.debug-openai-services-description') }}
<br />
<DocLink class="mt-2" link="/documentation/getting-started/installation/open-ai" />
</BaseCardSectionTitle>
</v-container>
<v-form ref="uploadForm" @submit.prevent="testOpenAI">
<div>
<v-card-text>
<v-container class="pa-0">
<v-row>
<v-col cols="auto" align-self="center">
<AppButtonUpload
v-if="!uploadedImage"
class="ml-auto"
url="none"
file-name="image"
accept="image/*"
:text="$i18n.tc('recipe.upload-image')"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
<v-btn
v-if="!!uploadedImage"
color="error"
@click="clearImage"
>
<v-icon left>{{ $globals.icons.close }}</v-icon>
{{ $i18n.tc("recipe.remove-image") }}
</v-btn>
</v-col>
<v-spacer />
</v-row>
<v-row v-if="uploadedImage && uploadedImagePreviewUrl" style="max-width: 25%;">
<v-spacer />
<v-col cols="12">
<v-img :src="uploadedImagePreviewUrl" />
</v-col>
<v-spacer />
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<BaseButton
type="submit"
:text="$i18n.tc('admin.run-test')"
:icon="$globals.icons.check"
:loading="loading"
class="ml-auto"
/>
</v-card-actions>
</div>
</v-form>
<v-divider v-if="response" class="mt-4" />
<v-container v-if="response" class="ma-0 pa-0">
<v-card-title> {{ $t('admin.test-results') }} </v-card-title>
<v-card-text> {{ response }} </v-card-text>
</v-container>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { VForm } from "~/types/vuetify";
export default defineComponent({
layout: "admin",
setup() {
const api = useAdminApi();
const loading = ref(false);
const response = ref("");
const uploadForm = ref<VForm | null>(null);
const uploadedImage = ref<Blob | File>();
const uploadedImageName = ref<string>("");
const uploadedImagePreviewUrl = ref<string>();
function uploadImage(fileObject: File) {
uploadedImage.value = fileObject;
uploadedImageName.value = fileObject.name;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() {
uploadedImage.value = undefined;
uploadedImageName.value = "";
uploadedImagePreviewUrl.value = undefined;
}
async function testOpenAI() {
response.value = "";
loading.value = true;
const { data } = await api.debug.debugOpenAI(uploadedImage.value);
loading.value = false;
if (!data) {
alert.error("Unable to test OpenAI services");
} else {
response.value = data.response || (data.success ? "Test Successful" : "Test Failed");
}
}
return {
loading,
response,
uploadForm,
uploadedImage,
uploadedImagePreviewUrl,
uploadImage,
clearImage,
testOpenAI,
};
},
head() {
return {
title: this.$t("admin.debug-openai-services"),
};
},
});
</script>

View File

@ -3,7 +3,7 @@ import os
import secrets
from datetime import datetime, timezone
from pathlib import Path
from typing import NamedTuple
from typing import Any, NamedTuple
from dateutil.tz import tzlocal
from pydantic import field_validator
@ -305,6 +305,10 @@ class AppSettings(AppLoggingSettings):
"""Your OpenAI API key. Required to enable OpenAI features"""
OPENAI_MODEL: str = "gpt-4o"
"""Which OpenAI model to send requests to. Leave this unset for most usecases"""
OPENAI_CUSTOM_HEADERS: dict[str, str] = {}
"""Custom HTTP headers to send with each OpenAI request"""
OPENAI_CUSTOM_PARAMS: dict[str, Any] = {}
"""Custom HTTP parameters to send with each OpenAI request"""
OPENAI_ENABLE_IMAGE_SERVICES: bool = True
"""Whether to enable image-related features in OpenAI"""
OPENAI_WORKERS: int = 2

View File

@ -3,6 +3,7 @@ from mealie.routes._base.routers import AdminAPIRouter
from . import (
admin_about,
admin_backups,
admin_debug,
admin_email,
admin_maintenance,
admin_management_groups,
@ -19,3 +20,4 @@ router.include_router(admin_management_groups.router, tags=["Admin: Manage Group
router.include_router(admin_email.router, tags=["Admin: Email"])
router.include_router(admin_backups.router, tags=["Admin: Backups"])
router.include_router(admin_maintenance.router, tags=["Admin: Maintenance"])
router.include_router(admin_debug.router, tags=["Admin: Debug"])

View File

@ -0,0 +1,52 @@
import os
import shutil
from fastapi import APIRouter, File, UploadFile
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.routes._base import BaseAdminController, controller
from mealie.schema.admin.debug import DebugResponse
from mealie.services.openai import OpenAILocalImage, OpenAIService
router = APIRouter(prefix="/debug")
@controller(router)
class AdminDebugController(BaseAdminController):
@router.post("/openai", response_model=DebugResponse)
async def debug_openai(self, image: UploadFile | None = File(None)):
if not self.settings.OPENAI_ENABLED:
return DebugResponse(success=False, response="OpenAI is not enabled")
if image and not self.settings.OPENAI_ENABLE_IMAGE_SERVICES:
return DebugResponse(
success=False, response="Image was provided, but OpenAI image services are not enabled"
)
with get_temporary_path() as temp_path:
if image:
with temp_path.joinpath(image.filename).open("wb") as buffer:
shutil.copyfileobj(image.file, buffer)
local_image_path = temp_path.joinpath(image.filename)
local_images = [OpenAILocalImage(filename=os.path.basename(local_image_path), path=local_image_path)]
else:
local_images = None
try:
openai_service = OpenAIService()
prompt = openai_service.get_prompt("debug")
message = "Hello, checking to see if I can reach you."
if local_images:
message = f"{message} Here is an image to test with:"
response = await openai_service.get_response(
prompt, message, images=local_images, force_json_response=False
)
return DebugResponse(success=True, response=f'OpenAI is working. Response: "{response}"')
except Exception as e:
self.logger.exception(e)
return DebugResponse(
success=False,
response=f'OpenAI request failed. Full error has been logged. {e.__class__.__name__}: "{e}"',
)

View File

@ -1,6 +1,7 @@
# This file is auto-generated by gen_schema_exports.py
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig, OIDCInfo
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
from .debug import DebugResponse
from .email import EmailReady, EmailSuccess, EmailTest
from .maintenance import MaintenanceLogs, MaintenanceStorageDetails, MaintenanceSummary
from .migration import ChowdownURL, MigrationFile, MigrationImport, Migrations
@ -49,4 +50,5 @@ __all__ = [
"EmailReady",
"EmailSuccess",
"EmailTest",
"DebugResponse",
]

View File

@ -0,0 +1,6 @@
from mealie.schema._mealie import MealieModel
class DebugResponse(MealieModel):
success: bool
response: str | None = None

View File

@ -90,6 +90,8 @@ class OpenAIService(BaseService):
base_url=settings.OPENAI_BASE_URL,
api_key=settings.OPENAI_API_KEY,
timeout=settings.OPENAI_REQUEST_TIMEOUT,
default_headers=settings.OPENAI_CUSTOM_HEADERS,
default_query=settings.OPENAI_CUSTOM_PARAMS,
)
super().__init__()
@ -176,6 +178,5 @@ class OpenAIService(BaseService):
if not response.choices:
return None
return response.choices[0].message.content
except Exception:
self.logger.exception("OpenAI Request Failed")
return None
except Exception as e:
raise Exception(f"OpenAI Request Failed. {e.__class__.__name__}: {e}") from e

View File

@ -0,0 +1 @@
You are a simple chatbot being used for debugging purposes.

View File

@ -80,10 +80,20 @@ class OpenAIParser(ABCIngredientParser):
tasks.append(service.get_response(prompt, message, force_json_response=True))
# re-combine chunks into one response
try:
responses_json = await asyncio.gather(*tasks)
except Exception as e:
raise Exception("Failed to call OpenAI services") from e
try:
responses = [
OpenAIIngredients.parse_openai_response(response_json) for response_json in responses_json if responses_json
OpenAIIngredients.parse_openai_response(response_json)
for response_json in responses_json
if responses_json
]
except Exception as e:
raise Exception("Failed to parse OpenAI response") from e
if not responses:
raise Exception("No response from OpenAI")

View File

@ -487,7 +487,13 @@ class OpenAIRecipeService(RecipeServiceBase):
if translate_language:
message += f" Please translate the recipe to {translate_language}."
response = await openai_service.get_response(prompt, message, images=openai_images, force_json_response=True)
try:
response = await openai_service.get_response(
prompt, message, images=openai_images, force_json_response=True
)
except Exception as e:
raise Exception("Failed to call OpenAI services") from e
try:
openai_recipe = OpenAIRecipe.parse_openai_response(response)
recipe = self._convert_recipe(openai_recipe)

View File

@ -11,6 +11,8 @@ admin_backups = "/api/admin/backups"
"""`/api/admin/backups`"""
admin_backups_upload = "/api/admin/backups/upload"
"""`/api/admin/backups/upload`"""
admin_debug_openai = "/api/admin/debug/openai"
"""`/api/admin/debug/openai`"""
admin_email = "/api/admin/email"
"""`/api/admin/email`"""
admin_groups = "/api/admin/groups"