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

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
docs/docs
documentation/getting-started/installation
overrides
frontend
components/Layout/LayoutParts
lang/messages
layouts
lib/api
pages/admin/debug
mealie
core/settings
routes/admin
schema/admin
services
openai
parser_services/openai
recipe
tests/utils/api_routes

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

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

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

@ -92,10 +92,23 @@ export default defineComponent({
restricted: true,
},
{
icon: $globals.icons.slotMachine,
to: "/admin/parser",
title: i18n.tc("sidebar.parser"),
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/debug/parser",
title: i18n.tc("sidebar.parser"),
restricted: true,
},
]
},
];

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

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

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

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

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

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

@ -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}"',
)

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

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

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

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

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

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

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