feat: In-app AI Provider Configuration (#7650)

This commit is contained in:
Michael Genson
2026-05-23 11:13:10 -05:00
committed by GitHub
parent f6fe92b400
commit c3f87736d0
86 changed files with 3325 additions and 297 deletions

View File

@@ -58,9 +58,6 @@ load_secrets() {
"OIDC_CONFIGURATION_URL"
"OIDC_CLIENT_ID"
"OIDC_CLIENT_SECRET"
"OPENAI_BASE_URL"
"OPENAI_API_KEY"
)
# If any secrets are set, prefer them over base environment variables.

View File

@@ -1,10 +1,10 @@
!!! info
This guide was submitted by a community member. Find something wrong? Submit a PR to get it fixed!
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
An easy way to add recipes to Mealie from an Apple device is via an Apple Shortcut. This is a short guide to install an configure a shortcut able to add recipes via a link or image(s).
!!! note
If adding via images make sure to enable [Mealie's OpenAI Integration](https://docs.mealie.io/documentation/getting-started/installation/open-ai/)
If adding via images make sure to enable [Mealie's AI Integration](https://docs.mealie.io/documentation/getting-started/installation/ai-providers)
## Javascript can only be run via Shortcuts on the Safari browser on MacOS and iOS. If you do not use Safari you may skip this section
Some sites have begun blocking AI scraping bots, inadvertently blocking the recipe scraping library Mealie uses as well. To circumvent this, the shortcut uses javascript to capture the raw html loaded in the browser and sends that to mealie when possible.
@@ -23,7 +23,7 @@ An API key is needed to authenticate with mealie. To create an api key for a use
The shortcut can be installed via **[This link](https://www.icloud.com/shortcuts/52834724050b42aebe0f2efd8d067360)**. Upon install, replace "MEALIE_API_KEY" with the API key generated previously and "MEALIE_URI" with the full URL used to access your mealie instance e.g. "http://10.0.0.5:9000" or "https://mealie.domain.com".
## Using the Shortcut
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
Once installed, the shortcut will automatically appear as an option when sharing an image or webpage. It can also be useful to add the shortcut to the home screen of your device. If selected from the home screen or shortcuts app, a menu will appear with prompts to import via **taking photo(s)**, **selecting photo(s)**, **scanning a URL**, or **pasting a URL**.
!!! note
Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.
Despite the Mealie API being able to accept multiple recipe images for import it is currently impossible to send multiple files in 1 web request via Shortcuts. Instead, the shortcut combines the images into a singular, vertically-concatenated image to send to mealie. This can result in slightly less-accurate text recognition.

View File

@@ -33,7 +33,7 @@
6. Click the Edit button/icon again
7. Scroll to the ingredients and you should see new fields for Amount, Unit, Food, and Note. The Note in particular will contain the original text of the Recipe.
8. Click `Parse` and you will be taken to the ingredient parsing page.
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled OpenAI support](./installation/backend-config.md#openai).
9. Choose your parser. The `Natural Language Parser` works very well, but you can also use the `Brute Parser`, or the `OpenAI Parser` if you've [enabled AI support](./installation/ai-providers.md).
10. Click `Parse All`, and your ingredients should be separated out into Units and Foods based on your seeding in Step 1 above.
11. For ingredients where the Unit or Food was not found, you can click a button to accept an automatically suggested Food to add to the database. Or, manually enter the Unit/Food and hit `Enter` (or click `Create`) to add it to the database
12. When done, click `Save All` and you will be taken back to the recipe. Now the Unit and Food fields of the recipe should be filled out.

View File

@@ -11,7 +11,7 @@ Mealie offers several ways to create recipes:
- **Recipe HTML or JSON:** Copy/paste structured HTML or JSON and Mealie can import it.
- **Manual Editor:** Create recipes from scratch using the integrated editor.
Mealie's [AI integration](./installation/open-ai.md) greatly expands the ways you can create recipes:
Mealie's [AI integration](./installation/ai-providers.md) greatly expands the ways you can create recipes:
- **Image Import:** Upload an image of a written or typed recipe and Mealie will use OCR and AI to import it.
- **Video URL Import:** Provide a video URL (e.g., YouTube) and Mealie will transcribe the audio and turn it into a recipe.

View File

@@ -0,0 +1,29 @@
# AI Integration
:octicons-tag-24: v1.7.0
Mealie's AI integration enables several features and enhancements throughout the application. To enable AI features, you must have access to an AI provider (such as OpenAI). Mealie works with any OpenAI-compatible API.
## Configuration
To set up AI providers, visit your group settings.
[Group Settings Demo](https://demo.mealie.io/group){ .md-button .md-button--primary }
- To enable AI features at all, you *must* set a default provider (e.g. `gpt-5`)
- To enable image recognition features, such as creating a recipe from an image, configure a provider capable of image recognition (e.g. `gpt-5`)
- To enable audio transcription features, such as importing a recipe from a video, configure a provider capable of audio transcriptions (e.g. `whisper-1`)
For most users, choosing an OpenAI model (such as `gpt-5`) and supplying the OpenAI API key is all you need to do. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
If you have another provider you'd like to use, such as Azure, you can configure Mealie to use that instead as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, set your `base_url` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
Note that some models are capable of handling multiple features (e.g. `gpt-5` can handle both normal chat requests and image recognition requests). You may configure one provider for multiple provider features.
While Mealie has prompts for each AI task, you can override these with your own prompts if you'd like. For more information, check out the [backend configuration](./installation/ai-providers.md).
## AI Features
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed AI, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)

View File

@@ -120,22 +120,10 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.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"}'`)
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./ai-providers.md).
| Variables | Default | Description |
|-------------------------------------------------------------------------|:-----------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | 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<super>[&dagger;][secrets]</super> | 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_AUDIO_MODEL <br/> :octicons-tag-24: v3.13.0 | whisper-1 | Which OpenAI model to use for audio transcriptions, if enabled. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | 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 <br/> :octicons-tag-24: v2.0.0 | 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 <br/> :octicons-tag-24: v1.12.0 | 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_ENABLE_TRANSCRIPTION_SERVICES <br/> :octicons-tag-24: v3.13.0 | True | Whether to enable OpenAI transcription services, such as creating recipes via video URL. 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 | 300 | 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 |
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None. | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
### Theming
@@ -315,7 +303,6 @@ at least these sensitive environment variables when working within shared enviro
- `POSTGRES_PASSWORD`
- `SMTP_PASSWORD`
- `LDAP_QUERY_PASSWORD`
- `OPENAI_API_KEY`
[docker-secrets]: https://docs.docker.com/compose/use-secrets/
[secrets]: #docker-secrets

View File

@@ -1,24 +0,0 @@
# OpenAI Integration
:octicons-tag-24: v1.7.0
Mealie's OpenAI integration enables several features and enhancements throughout the application. To enable OpenAI features, you must have an account with OpenAI and configure Mealie to use the OpenAI API key (for more information, check out the [backend configuration](./backend-config.md#openai)).
## Configuration
For most users, supplying the OpenAI API key is all you need to do; you will use the regular OpenAI service with the default language model. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`.
If you wish to disable transcription features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_TRANSCRIPTION_SERVICES` to `False`.
For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
## OpenAI Features
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
- You can import a recipe via a video URL (e.g., a YouTube link). The video is transcribed using OpenAI's Whisper model, and the transcription is parsed into a recipe (:octicons-tag-24: v3.13.0)

View File

@@ -76,7 +76,7 @@ nav:
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
- Security: "documentation/getting-started/installation/security.md"
- Logs: "documentation/getting-started/installation/logs.md"
- OpenAI: "documentation/getting-started/installation/open-ai.md"
- AI Providers: "documentation/getting-started/installation/ai-providers.md"
- Usage:
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
- Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md"

View File

@@ -0,0 +1,63 @@
<template>
<div>
<p>AI providers can now be configured directly in Mealie, without managing environment variables or secrets.</p>
<div class="mb-2">
AI providers enable features such as:
<ul class="ml-6">
<li>Creating recipes from images</li>
<li>Importing recipes from videos (YouTube, TikTok, etc.)</li>
<li>Enhanced ingredient parsing</li>
<li>And more!</li>
</ul>
</div>
<hr class="mt-2 mb-4">
<p>
<span v-if="group?.aiProviderSettings?.aiEnabled">
Your group already has AI providers configured.
</span>
<span v-else>
Your group does not currently have any AI providers configured.
</span>
<span v-if="user?.canManage">
You can manage them here:
<br>
<v-btn class="mt-2" color="primary" to="/group">
{{ $t("profile.group-settings") }}
</v-btn>
</span>
<span v-else-if="!group?.aiProviderSettings?.aiEnabled">
Contact a group manager or server admin to set up AI providers for your group.
</span>
</p>
<div v-if="user?.admin">
<br>
<p>
As an admin, you can configure AI providers for any group. Unlike the old environment variable approach, providers are configured per-group:
<br>
<v-btn class="mt-2" color="primary" to="/admin/manage/groups">
{{ $t("group.admin-group-management") }}
</v-btn>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useGroupSelf } from "~/composables/use-groups";
import type { AnnouncementMeta } from "~/composables/use-announcements";
const { user } = useMealieAuth();
const { group } = useGroupSelf();
</script>
<script lang="ts">
export const meta: AnnouncementMeta = {
title: "Improved AI Provider Configuration",
};
</script>
<style scoped lang="css">
p {
padding-bottom: 8px;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<BaseDialog
v-model="dialog"
:title="isEdit ? $t('group.ai-provider-settings.edit-provider') : $t('group.ai-provider-settings.create-provider')"
:icon="$globals.icons.robot"
:loading="loading"
can-submit
:submit-icon="isEdit ? $globals.icons.save : $globals.icons.createAlt"
:submit-text="isEdit ? $t('general.update') : $t('general.create')"
:submit-disabled="submitDisabled"
@submit="handleSubmit"
@close="resetForm"
>
<v-card-text v-if="init" style="max-height: 70vh; overflow-y: auto;">
<v-form ref="form" v-no-autofill>
<v-text-field
v-model="formData.name"
:label="$t('group.ai-provider-settings.provider-name')"
:rules="[validators.required]"
density="compact"
variant="outlined"
class="mb-4"
/>
<v-text-field
v-model="formData.model"
:label="$t('group.ai-provider-settings.model')"
:hint="$t('group.ai-provider-settings.model-description')"
:rules="[validators.required]"
density="compact"
variant="outlined"
class="mb-4"
/>
<v-text-field
v-model="formData.apiKey"
:label="$t('group.ai-provider-settings.api-key')"
:hint="$t(
isEdit
? 'group.ai-provider-settings.api-key-description-edit'
: 'group.ai-provider-settings.api-key-description-create',
)"
:persistent-hint="isEdit"
:rules="isEdit ? [] : [validators.required]"
density="compact"
variant="outlined"
type="password"
class="mb-4"
/>
<v-text-field
v-model="formData.baseUrl"
:label="$t('group.ai-provider-settings.base-url')"
:hint="$t('group.ai-provider-settings.base-url-description')"
density="compact"
variant="outlined"
class="mb-4"
/>
<v-number-input
v-model.number="formData.timeout"
:label="$t('group.ai-provider-settings.request-timeout-seconds')"
type="number"
:min="0"
hide-details
control-variant="stacked"
density="compact"
variant="outlined"
class="mb-4"
/>
<v-expansion-panels v-model="advancedPanel" variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title class="text-subtitle-2" expand-icon="$expand" collapse-icon="$expand">
{{ $t('search.advanced') }}
</v-expansion-panel-title>
<v-expansion-panel-text class="px-0">
<div class="mb-2 text-subtitle-2">
{{ $t('group.ai-provider-settings.request-headers') }}
</div>
<BaseKeyValueEditor
v-model="formData.requestHeaders"
class="mb-4"
/>
<v-divider class="mb-4" />
<div class="mb-2 text-subtitle-2">
{{ $t('group.ai-provider-settings.request-params') }}
</div>
<BaseKeyValueEditor
v-model="formData.requestParams"
/>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-form>
</v-card-text>
<AppLoader v-else waiting-text="" />
</BaseDialog>
</template>
<script setup lang="ts">
import { useAIProviders } from "~/composables/use-ai-providers";
import { validators } from "~/composables/use-validators";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
const props = withDefaults(defineProps<{
providerId?: string;
}>(), {
providerId: undefined,
});
const emit = defineEmits<{
(e: "create", data: AIProviderCreate): void;
(e: "update", id: string, data: AIProviderUpdate): void;
}>();
const dialog = defineModel<boolean>({ default: false });
const { $globals } = useNuxtApp();
const { loading, getOne } = useAIProviders();
const init = ref(false);
const form = ref();
const advancedPanel = ref<number | undefined>(undefined);
const isEdit = computed(() => !!props.providerId);
const defaultForm = () => ({
name: "",
model: "",
apiKey: "",
baseUrl: "",
timeout: 300,
requestHeaders: {} as Record<string, string>,
requestParams: {} as Record<string, string>,
});
const formData = reactive(defaultForm());
const submitDisabled = computed(() => {
return !formData.name?.trim() || !formData.model?.trim() || (!isEdit.value && !formData.apiKey?.trim());
});
// Fetch existing provider when editing; reset form for create mode
watch(
() => [dialog.value, props.providerId] as const,
async ([open, id]) => {
if (!open) return;
if (!id) {
// Create mode — just show the empty form
resetForm();
init.value = true;
return;
}
init.value = false;
const { data } = await getOne(id);
init.value = true;
if (data) {
formData.name = data.name;
formData.model = data.model;
formData.apiKey = "";
formData.baseUrl = data.baseUrl ?? "";
formData.timeout = data.timeout ?? 300;
formData.requestHeaders = { ...(data.requestHeaders ?? {}) };
formData.requestParams = { ...(data.requestParams ?? {}) };
}
},
{ immediate: true },
);
function handleSubmit() {
// Required field guard (button is also disabled, but keep as a safeguard)
if (!formData.name?.trim() || !formData.model?.trim()) return;
if (!isEdit.value && !formData.apiKey?.trim()) return;
if (isEdit.value && props.providerId) {
const payload: AIProviderUpdate & { apiKey?: string } = {
name: formData.name,
model: formData.model,
baseUrl: formData.baseUrl || null,
timeout: formData.timeout,
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
};
if (formData.apiKey) {
payload.apiKey = formData.apiKey;
}
emit("update", props.providerId, payload);
}
else {
const createPayload = {
name: formData.name,
model: formData.model,
apiKey: formData.apiKey,
baseUrl: formData.baseUrl || null,
timeout: formData.timeout,
requestHeaders: Object.keys(formData.requestHeaders).length ? formData.requestHeaders : undefined,
requestParams: Object.keys(formData.requestParams).length ? formData.requestParams : undefined,
};
emit("create", createPayload as AIProviderCreate);
}
}
function resetForm() {
Object.assign(formData, defaultForm());
form.value?.reset();
advancedPanel.value = undefined;
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div v-if="providerSettings">
<BaseCardSectionTitle v-if="!hideHeader" :title="$t('group.ai-provider-settings.ai-provider-settings')">
<template v-if="noDefaultProviderWarning" #append-title>
<v-tooltip location="bottom" color="warning">
<template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" size="small" color="warning" class="ms-2">
{{ $globals.icons.alert }}
</v-icon>
</template>
<span>{{ $t('group.ai-provider-settings.no-default-provider-warning') }}</span>
</v-tooltip>
</template>
</BaseCardSectionTitle>
<v-card-text v-if="!hideHeader" class="pt-0 pb-10 px-0">
{{ $t("group.ai-provider-settings.ai-provider-settings-description") }}
</v-card-text>
<v-row class="mb-4">
<v-col cols="12">
<v-autocomplete
v-model="local.defaultProviderId"
:label="$t('group.ai-provider-settings.default-provider')"
:items="local.providers"
item-title="name"
item-value="id"
clearable
hide-details
density="compact"
variant="outlined"
/>
<v-card-subtitle class="mt-1">
{{ $t("group.ai-provider-settings.default-provider-description") }}
</v-card-subtitle>
</v-col>
<v-col cols="12">
<v-autocomplete
v-model="local.audioProviderId"
:label="$t('group.ai-provider-settings.audio-provider')"
:items="local.providers"
item-title="name"
item-value="id"
clearable
hide-details
density="compact"
variant="outlined"
/>
<v-card-subtitle class="mt-1">
{{ $t("group.ai-provider-settings.audio-provider-description") }}
</v-card-subtitle>
</v-col>
<v-col cols="12">
<v-autocomplete
v-model="local.imageProviderId"
:label="$t('group.ai-provider-settings.image-provider')"
:items="local.providers"
item-title="name"
item-value="id"
clearable
hide-details
density="compact"
variant="outlined"
/>
<v-card-subtitle class="mt-1">
{{ $t("group.ai-provider-settings.image-provider-description") }}
</v-card-subtitle>
</v-col>
</v-row>
<GroupAIProviderDialog
v-model="dialogOpen"
:provider-id="editingProviderId ?? undefined"
@create="(data) => $emit('create', data)"
@update="(id, data) => $emit('update', id, data)"
/>
<BaseCardSectionTitle
:title="$t('group.ai-provider-settings.providers')"
size="medium"
class="pt-2"
>
<template #append-title>
<BaseButton
:text="$t('group.ai-provider-settings.create-provider')"
class="ms-auto my-2"
create
small
@click="openCreate"
/>
</template>
</BaseCardSectionTitle>
<v-card
v-for="provider in local.providers"
:key="provider.id"
variant="tonal"
class="pa-0 mb-4"
>
<v-row no-gutters>
<v-col :cols="10">
<v-card-text>
{{ provider.name }}
</v-card-text>
</v-col>
<v-col :cols="2">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.edit,
text: $t('general.edit'),
event: 'edit',
},
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]"
@edit="openEdit(provider.id)"
@delete="$emit('delete', provider.id)"
/>
</v-col>
</v-row>
</v-card>
</div>
</template>
<script setup lang="ts">
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
import type { AIProviderSettingsOut } from "~/lib/api/types/user";
const providerSettings = defineModel<AIProviderSettingsOut>({ required: true });
const props = withDefaults(defineProps<{
hideHeader?: boolean;
}>(), {
hideHeader: false,
});
const { hideHeader } = toRefs(props);
const local = reactive({ ...providerSettings.value });
watch(local, (newVal) => { providerSettings.value = { ...newVal }; });
// Sync back when the parent refreshes after create/update/delete
watch(providerSettings, (newVal) => { if (newVal) Object.assign(local, newVal); });
const noDefaultProviderWarning = computed(
() => local.providers.length > 0 && !local.defaultProviderId,
);
defineEmits<{
(e: "create", data: AIProviderCreate): void;
(e: "update", id: string, data: AIProviderUpdate): void;
(e: "delete", id: string): void;
}>();
const dialogOpen = ref(false);
const editingProviderId = ref<string | null>(null);
function openCreate() {
editingProviderId.value = null;
dialogOpen.value = true;
}
function openEdit(id: string) {
editingProviderId.value = id;
dialogOpen.value = true;
}
</script>

View File

@@ -200,6 +200,7 @@ import { useUserApi } from "~/composables/api";
import { useIngredientTextParser } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { useGroupSelf } from "~/composables/use-groups";
import { alert } from "~/composables/use-toast";
import { useParsingPreferences } from "~/composables/use-users/preferences";
@@ -215,7 +216,7 @@ const emit = defineEmits<{
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>();
const { $appInfo } = useNuxtApp();
const { group } = useGroupSelf();
const i18n = useGlobalI18n();
const api = useUserApi();
const drag = ref(false);
@@ -240,7 +241,7 @@ const availableParsers = computed(() => {
{
text: i18n.t("recipe.parser.openai-parser"),
value: "openai",
hide: !$appInfo.enableOpenai,
hide: !group.value?.aiProviderSettings?.aiEnabled,
},
];
});

View File

@@ -4,7 +4,7 @@
style="border-color: lightgrey;"
:to="link.to"
height="100%"
class="d-flex flex-column mt-4"
class="d-flex flex-column mt-4 pa-2"
>
<div
v-if="$vuetify.display.smAndDown"

View File

@@ -96,15 +96,17 @@
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SideBarLink } from "~/types/application-types";
import { useGroupSelf } from "~/composables/use-groups";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
const i18n = useI18n();
const { $appInfo, $globals } = useNuxtApp();
const { $globals } = useNuxtApp();
const display = useDisplay();
const auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const { group } = useGroupSelf();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
@@ -131,7 +133,7 @@ const cookbooks = computed(() => {
return [];
});
const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
const showImageImport = computed(() => group.value?.aiProviderSettings?.imageProviderEnabled);
const sidebar = ref<boolean>(false);
onMounted(() => {

View File

@@ -7,7 +7,8 @@
'mt-8': section,
}"
>
<v-card-title class="text-h5 pl-0 py-0" style="font-weight: normal;">
<v-card-title :class="`text-title-${size} pl-0 py-0 d-flex align-center`" style="font-weight: normal;">
<slot name="prepend-title" />
<v-icon
v-if="icon"
size="small"
@@ -16,6 +17,7 @@
{{ icon }}
</v-icon>
{{ title }}
<slot name="append-title" />
</v-card-title>
<v-card-text
v-if="$slots.default"
@@ -30,11 +32,17 @@
</template>
<script setup lang="ts">
type Size = "large" | "medium" | "small";
defineProps({
title: {
type: String,
required: true,
},
size: {
type: String as () => Size,
default: "large",
},
icon: {
type: String,
default: "",

View File

@@ -0,0 +1,127 @@
<template>
<div>
<div
v-for="(value, key) in (modelValue ?? {})"
:key="key"
class="d-flex align-center mb-2 gap-2"
>
<v-text-field
:model-value="key"
:label="resolvedKeyLabel"
density="compact"
variant="outlined"
hide-details
readonly
class="me-3 flex-grow-1"
/>
<v-text-field
:model-value="value"
:label="resolvedValueLabel"
density="compact"
variant="outlined"
hide-details
class="ms-3 flex-grow-1"
@update:model-value="updateValue(key, $event)"
/>
<v-btn
icon
variant="text"
color="error"
size="small"
@click="removeEntry(key)"
>
<v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn>
</div>
<div class="d-flex align-center mt-2 gap-2" @focusout="onNewEntryFocusOut">
<v-text-field
v-model="newKey"
:label="resolvedKeyLabel"
density="compact"
variant="outlined"
hide-details
class="me-3 flex-grow-1"
@keydown.enter.prevent="addEntry"
/>
<v-text-field
v-model="newValue"
:label="resolvedValueLabel"
density="compact"
variant="outlined"
hide-details
class="ms-3 flex-grow-1"
@keydown.enter.prevent="addEntry"
/>
<v-btn
icon
variant="text"
color="primary"
size="small"
:disabled="!newKey?.trim()"
@click="addEntry"
>
<v-icon>{{ $globals.icons.createAlt }}</v-icon>
</v-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { useGlobalI18n } from "~/composables/use-global-i18n";
const i18n = useGlobalI18n();
const props = defineProps<{
modelValue?: Record<string, string> | null;
keyLabel?: string;
valueLabel?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: Record<string, string>): void;
}>();
const { $globals } = useNuxtApp();
const resolvedKeyLabel = computed(() => props.keyLabel ?? i18n.t("general.key"));
const resolvedValueLabel = computed(() => props.valueLabel ?? i18n.t("general.value"));
const newKey = ref("");
const newValue = ref("");
function current(): Record<string, string> {
return { ...(props.modelValue ?? {}) };
}
function addEntry() {
const key = newKey.value?.trim();
if (!key) return;
const updated = current();
updated[key] = newValue.value;
emit("update:modelValue", updated);
newKey.value = "";
newValue.value = "";
}
function onNewEntryFocusOut(e: FocusEvent) {
const relatedTarget = e.relatedTarget as HTMLElement | null;
const currentTarget = e.currentTarget as HTMLElement;
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
addEntry();
}
}
function updateValue(key: string, value: string) {
const updated = current();
updated[key] = value;
emit("update:modelValue", updated);
}
function removeEntry(key: string) {
const updated = current();
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete updated[key];
emit("update:modelValue", updated);
}
</script>

View File

@@ -0,0 +1,55 @@
import { useUserApi } from "~/composables/api";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
export function useAIProviders() {
const api = useUserApi();
const loading = ref(false);
async function getOne(id: string) {
loading.value = true;
try {
return await api.aiProviders.getOne(id);
}
finally {
loading.value = false;
}
}
async function createOne(payload: AIProviderCreate) {
loading.value = true;
try {
return await api.aiProviders.createOne(payload);
}
finally {
loading.value = false;
}
}
async function updateOne(id: string, payload: AIProviderUpdate) {
loading.value = true;
try {
return await api.aiProviders.updateOne(id, payload);
}
finally {
loading.value = false;
}
}
async function deleteOne(id: string) {
loading.value = true;
try {
return await api.aiProviders.deleteOne(id);
}
finally {
loading.value = false;
}
}
return {
loading: readonly(loading),
getOne,
createOne,
updateOne,
deleteOne,
};
}

View File

@@ -22,7 +22,10 @@ const allAnnouncements: Announcement[] = Object.entries(_announcementsUnsorted)
.map(([path, mod]) => {
const key = path.split("/").at(-1)!.replace(".vue", "");
const parsed = new Date(key.split("_", 1)[0]!);
const dateParts = key.split("_", 1)[0]!.split("-").map(Number);
const parsed = dateParts.length === 3
? new Date(dateParts[0]!, dateParts[1]! - 1, dateParts[2]!)
: new Date(NaN);
const date = isNaN(parsed.getTime()) ? undefined : parsed;
return {

View File

@@ -42,6 +42,25 @@ export const useGroupSelf = function () {
return data || undefined;
},
async updateAIProviderSettings() {
if (!groupSelfRef.value) {
await refreshGroupSelf();
}
if (!groupSelfRef.value?.aiProviderSettings) {
return;
}
const { data } = await api.groups.setAIProviderSettings(groupSelfRef.value.aiProviderSettings);
if (data) {
groupSelfRef.value.aiProviderSettings = data;
}
return data || undefined;
},
async refresh() {
await refreshGroupSelf();
},
};
const group = actions.get();

View File

@@ -223,7 +223,9 @@
"show-advanced": "Show Advanced",
"add-field": "Add Field",
"date-created": "Date Created",
"date-updated": "Date Updated"
"date-updated": "Date Updated",
"key": "Key",
"value": "Value"
},
"group": {
"are-you-sure-you-want-to-delete-the-group": "Are you sure you want to delete <b>{groupName}<b/>?",
@@ -283,7 +285,40 @@
"admin-group-management-text": "Changes to this group will be reflected immediately.",
"group-id-value": "Group Id: {0}",
"total-households": "Total Households",
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household"
"you-must-select-a-group-before-selecting-a-household": "You must select a group before selecting a household",
"ai-provider-settings": {
"ai-provider-settings": "AI Provider Settings",
"ai-provider": "AI Provider",
"ai-providers": "AI Providers",
"ai-provider-settings-description": "Configure AI providers to enable AI-powered features, such as enhanced ingredient parsing, creating recipes from videos, and more!",
"providers": "Providers",
"create-provider": "Create Provider",
"edit-provider": "Edit Provider",
"default-provider": "Default Provider",
"default-provider-description": "Required to enable AI features",
"audio-provider": "Audio Provider",
"audio-provider-description": "Enables audio transcription features, such as creating recipes from videos",
"image-provider": "Image Provider",
"image-provider-description": "Enables image recognition features, such as creating recipes from images",
"provider-name": "Provider Name",
"api-key": "API Key",
"api-key-description-create": "Your provider's API key for authentication. If your service (e.g. Ollama) doesn't use an API key, you still have to put something here.",
"api-key-description-edit": "Leave this blank unless you want to change it.",
"base-url": "Base URL",
"base-url-description": "If you're using OpenAI leave this blank. Must be an OpenAI-compatible endpoint (e.g. \"http://localhost:11434/v1\").",
"model": "Model",
"model-description": "Which model your AI provider should use (e.g. \"gpt-5\").",
"request-timeout-seconds": "Request Timeout (seconds)",
"provider-created": "Provider created",
"provider-updated": "Provider updated",
"provider-deleted": "Provider deleted",
"provider-create-failed": "Failed to create provider",
"provider-update-failed": "Failed to update provider",
"provider-delete-failed": "Failed to delete provider",
"request-headers": "Request Headers",
"request-params": "Request Parameters",
"no-default-provider-warning": "You have not set a default provider, so AI features are disabled"
}
},
"household": {
"household": "Household",
@@ -1362,6 +1397,8 @@
"already-set-up-bring-to-homepage": "I'm already set up, just bring me to the homepage",
"common-settings-for-new-sites": "Here are some common settings for new sites",
"setup-complete": "Setup Complete!",
"ai-providers-description": "Optionally configure AI providers for your group. AI providers enable features like creating recipes from images, importing recipes from videos, and enhanced ingredient parsing. You can always configure this later from your group settings.",
"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."

View File

@@ -0,0 +1,27 @@
import { BaseAPI } from "../base/base-clients";
import type { AIProviderCreate, AIProviderOut, AIProviderUpdate } from "~/lib/api/types/group";
const prefix = "/api/admin";
const routes = {
providers: (groupId: string) => `${prefix}/groups/${groupId}/ai-providers/providers`,
providersId: (groupId: string, providerId: string) => `${prefix}/groups/${groupId}/ai-providers/providers/${providerId}`,
};
export class AdminAIProvidersApi extends BaseAPI {
async createProvider(groupId: string, payload: AIProviderCreate) {
return await this.requests.post<AIProviderOut>(routes.providers(groupId), payload);
}
async getProvider(groupId: string, providerId: string) {
return await this.requests.get<AIProviderOut>(routes.providersId(groupId, providerId));
}
async updateProvider(groupId: string, providerId: string, payload: AIProviderUpdate) {
return await this.requests.put<AIProviderOut>(routes.providersId(groupId, providerId), payload);
}
async deleteProvider(groupId: string, providerId: string) {
return await this.requests.delete<AIProviderOut>(routes.providersId(groupId, providerId));
}
}

View File

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

View File

@@ -6,6 +6,7 @@ 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 { AdminAIProvidersApi } from "./admin/admin-ai-providers";
import type { ApiRequestInstance } from "~/lib/api/types/non-generated";
export class AdminAPI {
@@ -17,6 +18,7 @@ export class AdminAPI {
public maintenance: AdminMaintenanceApi;
public analytics: AdminAnalyticsApi;
public debug: AdminDebugAPI;
public aiProviders: AdminAIProvidersApi;
constructor(requests: ApiRequestInstance) {
this.about = new AdminAboutAPI(requests);
@@ -27,6 +29,7 @@ export class AdminAPI {
this.maintenance = new AdminMaintenanceApi(requests);
this.analytics = new AdminAnalyticsApi(requests);
this.debug = new AdminDebugAPI(requests);
this.aiProviders = new AdminAIProvidersApi(requests);
Object.freeze(this);
}

View File

@@ -24,6 +24,7 @@ import { MultiPurposeLabelsApi } from "./user/group-multiple-purpose-labels";
import { GroupEventNotifierApi } from "./user/group-event-notifier";
import { MealPlanRulesApi } from "./user/group-mealplan-rules";
import { GroupDataSeederApi } from "./user/group-seeder";
import { AIProvidersAPI } from "./user/group-ai-providers";
import type { ApiRequestInstance } from "~/lib/api/types/non-generated";
export class UserApiClient {
@@ -53,6 +54,7 @@ export class UserApiClient {
public groupEventNotifier: GroupEventNotifierApi;
public upload: UploadFile;
public seeders: GroupDataSeederApi;
public aiProviders: AIProvidersAPI;
constructor(requests: ApiRequestInstance) {
// Recipes
@@ -80,6 +82,7 @@ export class UserApiClient {
this.shopping = new ShoppingApi(requests);
this.multiPurposeLabels = new MultiPurposeLabelsApi(requests);
this.seeders = new GroupDataSeederApi(requests);
this.aiProviders = new AIProvidersAPI(requests);
// Admin
this.backups = new BackupAPI(requests);

View File

@@ -16,9 +16,6 @@ export interface AdminAboutInfo {
enableOidc: boolean;
oidcRedirect: boolean;
oidcProviderName: string;
enableOpenai: boolean;
enableOpenaiImageServices: boolean;
enableOpenaiTranscriptionServices: boolean;
tokenTime: number;
versionLatest: string;
apiPort: number;
@@ -50,9 +47,6 @@ export interface AppInfo {
enableOidc: boolean;
oidcRedirect: boolean;
oidcProviderName: string;
enableOpenai: boolean;
enableOpenaiImageServices: boolean;
enableOpenaiTranscriptionServices: boolean;
tokenTime: number;
}
export interface AppStartupInfo {
@@ -95,7 +89,6 @@ export interface CheckAppConfig {
emailReady: boolean;
ldapReady: boolean;
oidcReady: boolean;
enableOpenai: boolean;
baseUrlSet: boolean;
isUpToDate: boolean;
}

View File

@@ -17,6 +17,77 @@ export type SupportedMigrations =
| "recipekeeper"
| "cookn";
export interface AIProviderCreate {
name: string;
baseUrl?: string | null;
model: string;
timeout?: number;
requestHeaders?: {
[k: string]: string;
};
requestParams?: {
[k: string]: string;
};
}
export interface AIProviderOut {
name: string;
baseUrl?: string | null;
model: string;
timeout?: number;
requestHeaders?: {
[k: string]: string;
};
requestParams?: {
[k: string]: string;
};
id: string;
}
export interface AIProviderSave {
name: string;
baseUrl?: string | null;
model: string;
timeout?: number;
requestHeaders?: {
[k: string]: string;
};
requestParams?: {
[k: string]: string;
};
settingsId: string;
}
export interface AIProviderSettingsCreate {
groupId: string;
}
export interface AIProviderSettingsOut {
defaultProviderId: string | null;
audioProviderId: string | null;
imageProviderId: string | null;
providers: AIProviderSummary[];
aiEnabled: boolean;
audioProviderEnabled: boolean;
imageProviderEnabled: boolean;
}
export interface AIProviderSummary {
id: string;
name: string;
}
export interface AIProviderSettingsUpdate {
defaultProviderId: string | null;
audioProviderId: string | null;
imageProviderId: string | null;
}
export interface AIProviderUpdate {
name: string;
baseUrl?: string | null;
model: string;
timeout?: number;
requestHeaders?: {
[k: string]: string;
};
requestParams?: {
[k: string]: string;
};
}
export interface CreateGroupPreferences {
privateGroup?: boolean;
showAnnouncements?: boolean;
@@ -29,6 +100,7 @@ export interface GroupAdminUpdate {
id: string;
name: string;
preferences?: UpdateGroupPreferences | null;
aiProviderSettings?: AIProviderSettingsUpdate | null;
}
export interface UpdateGroupPreferences {
privateGroup?: boolean;

View File

@@ -59,6 +59,7 @@ export interface GroupInDB {
households?: GroupHouseholdSummary[] | null;
users?: UserSummary[] | null;
preferences?: ReadGroupPreferences | null;
aiProviderSettings?: AIProviderSettingsOut | null;
}
export interface CategoryBase {
name: string;
@@ -89,11 +90,25 @@ export interface ReadGroupPreferences {
groupId: string;
id: string;
}
export interface AIProviderSettingsOut {
defaultProviderId: string | null;
audioProviderId: string | null;
imageProviderId: string | null;
providers: AIProviderSummary[];
aiEnabled: boolean;
audioProviderEnabled: boolean;
imageProviderEnabled: boolean;
}
export interface AIProviderSummary {
id: string;
name: string;
}
export interface GroupSummary {
name: string;
id: string;
slug: string;
preferences?: ReadGroupPreferences | null;
aiProviderSettings?: AIProviderSettingsOut | null;
}
export interface LongLiveTokenCreateResponse {
name: string;

View File

@@ -0,0 +1,27 @@
import { BaseAPI } from "../base/base-clients";
import type { AIProviderCreate, AIProviderOut, AIProviderUpdate } from "~/lib/api/types/group";
const prefix = "/api/groups/ai-providers";
const routes = {
providers: `${prefix}/providers`,
providersId: (id: string) => `${prefix}/providers/${id}`,
};
export class AIProvidersAPI extends BaseAPI {
async getOne(id: string) {
return await this.requests.get<AIProviderOut>(routes.providersId(id));
}
async createOne(payload: AIProviderCreate) {
return await this.requests.post<AIProviderOut>(routes.providers, payload);
}
async updateOne(id: string, payload: AIProviderUpdate) {
return await this.requests.put<AIProviderOut, AIProviderUpdate>(routes.providersId(id), payload);
}
async deleteOne(id: string) {
return await this.requests.delete<AIProviderOut>(routes.providersId(id));
}
}

View File

@@ -3,6 +3,8 @@ import type { PaginationData } from "../types/non-generated";
import type { QueryValue } from "../base/route";
import type { GroupBase, GroupInDB, GroupSummary, UserSummary } from "~/lib/api/types/user";
import type {
AIProviderSettingsUpdate,
AIProviderSettingsOut,
GroupAdminUpdate,
GroupStorage,
ReadGroupPreferences,
@@ -15,6 +17,7 @@ const routes = {
groups: `${prefix}/admin/groups`,
groupsSelf: `${prefix}/groups/self`,
preferences: `${prefix}/groups/preferences`,
aiProviderSettings: `${prefix}/groups/ai-providers/settings`,
storage: `${prefix}/groups/storage`,
members: `${prefix}/groups/members`,
groupsId: (id: string | number) => `${prefix}/admin/groups/${id}`,
@@ -29,15 +32,15 @@ export class GroupAPI extends BaseCRUDAPI<GroupBase, GroupInDB, GroupAdminUpdate
return await this.requests.get<GroupSummary>(routes.groupsSelf);
}
async getPreferences() {
return await this.requests.get<ReadGroupPreferences>(routes.preferences);
}
async setPreferences(payload: UpdateGroupPreferences) {
// TODO: This should probably be a patch request, which isn't offered by the API currently
return await this.requests.put<ReadGroupPreferences, UpdateGroupPreferences>(routes.preferences, payload);
}
async setAIProviderSettings(payload: AIProviderSettingsUpdate) {
return await this.requests.put<AIProviderSettingsOut, AIProviderSettingsUpdate>(routes.aiProviderSettings, payload);
}
async fetchMembers(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
return await this.requests.get<PaginationData<UserSummary>>(routes.members, { page, perPage, ...params });
}

View File

@@ -43,10 +43,6 @@ export class HouseholdAPI extends BaseCRUDAPIReadOnly<HouseholdSummary> {
return await this.requests.get<HouseholdRecipeSummary>(routes.householdsSelfRecipesSlug(recipeSlug));
}
async getPreferences() {
return await this.requests.get<ReadHouseholdPreferences>(routes.preferences);
}
async setPreferences(payload: UpdateHouseholdPreferences) {
// TODO: This should probably be a patch request, which isn't offered by the API currently
return await this.requests.put<ReadHouseholdPreferences, UpdateHouseholdPreferences>(routes.preferences, payload);

View File

@@ -6,7 +6,7 @@
<br>
<DocLink
class="mt-2"
link="/documentation/getting-started/installation/open-ai"
link="/documentation/getting-started/installation/ai-providers"
/>
</BaseCardSectionTitle>
</v-container>
@@ -17,6 +17,36 @@
<div>
<v-card-text>
<v-container class="pa-0">
<v-row>
<v-col cols="12" md="6">
<v-select
v-if="groups"
v-model="selectedGroupId"
:items="groups"
item-title="name"
item-value="id"
:label="$t('group.group')"
density="compact"
variant="outlined"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="selectedProviderId"
:items="groupProviders"
item-title="name"
item-value="id"
:label="$t('group.ai-provider-settings.ai-provider')"
density="compact"
variant="outlined"
clearable
hide-details
:disabled="!selectedGroupId"
/>
</v-col>
</v-row>
<v-row>
<v-col
cols="auto"
@@ -61,6 +91,7 @@
<v-card-actions>
<BaseButton
type="submit"
:disabled="!selectedProviderId"
:text="$t('admin.run-test')"
:icon="$globals.icons.check"
:loading="loading"
@@ -85,7 +116,9 @@
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { alert } from "~/composables/use-toast";
import type { AIProviderSummary } from "~/lib/api/types/group";
definePageMeta({
layout: "admin",
@@ -106,10 +139,24 @@ 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);
// Group + provider selection
const { groups } = useGroups();
const selectedGroupId = ref<string | null>(null);
const groupProviders = ref<AIProviderSummary[]>([]);
const selectedProviderId = ref<string | null>(null);
watch(selectedGroupId, (id) => {
groupProviders.value = [];
selectedProviderId.value = null;
if (!id) return;
const group = groups.value?.find(g => g.id === id);
groupProviders.value = group?.aiProviderSettings?.providers ?? [];
});
function uploadImage(fileObject: unknown) {
uploadedImage.value = fileObject as File;
uploadedImageName.value = (fileObject as File).name;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject as File);
}
function clearImage() {
@@ -119,10 +166,15 @@ function clearImage() {
}
async function testOpenAI() {
if (!selectedProviderId.value) {
alert.error("Please select a provider");
return;
}
response.value = "";
loading.value = true;
const { data } = await api.debug.debugOpenAI(uploadedImage.value);
const { data } = await api.debug.debugOpenAI(selectedProviderId.value, uploadedImage.value);
loading.value = false;
if (!data) {

View File

@@ -33,6 +33,13 @@
v-if="group.preferences"
v-model="group.preferences"
/>
<GroupAIProviderSettingsEditor
v-if="group.aiProviderSettings"
v-model="group.aiProviderSettings"
@create="handleCreateProvider"
@update="handleUpdateProvider"
@delete="handleDeleteProvider"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
@@ -50,8 +57,10 @@
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
import type { VForm } from "vuetify/components";
definePageMeta({
@@ -72,7 +81,7 @@ const adminApi = useAdminApi();
const userError = ref(false);
const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
const { data: group, refresh } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
if (!groupId.value) {
return null;
}
@@ -86,7 +95,7 @@ const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async
}, { watch: [groupId] });
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || group.value === null) {
if (!refGroupEditForm.value?.validate() || !group.value) {
return;
}
@@ -103,4 +112,40 @@ async function handleSubmit() {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
async function handleCreateProvider(data: AIProviderCreate) {
if (!group.value) return;
const result = await adminApi.aiProviders.createProvider(group.value.id, data);
if (result.data) {
await refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-created"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-create-failed"));
}
}
async function handleUpdateProvider(id: string, data: AIProviderUpdate) {
if (!group.value) return;
const result = await adminApi.aiProviders.updateProvider(group.value.id, id, data);
if (result.data) {
await refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-updated"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-update-failed"));
}
}
async function handleDeleteProvider(id: string) {
if (!group.value) return;
const result = await adminApi.aiProviders.deleteProvider(group.value.id, id);
if (result.data) {
await refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
}
}
</script>

View File

@@ -45,6 +45,14 @@
:title="$t('settings.site-settings')"
/>
<v-divider />
<v-stepper-item
:value="Pages.AI_PROVIDERS"
:icon="$globals.icons.robot"
:complete="currentPage > Pages.AI_PROVIDERS"
:color="getStepperColor(currentPage, Pages.AI_PROVIDERS)"
:title="$t('group.ai-provider-settings.ai-providers')"
/>
<v-divider />
<v-stepper-item
:value="Pages.CONFIRM"
:icon="$globals.icons.chefHat"
@@ -173,6 +181,43 @@
</v-stepper-actions>
</v-stepper-window-item>
<!-- AI PROVIDERS -->
<v-stepper-window-item :value="Pages.AI_PROVIDERS">
<v-container max-width="880">
<v-card-title class="headline pa-0">
{{ $t('group.ai-provider-settings.ai-providers') }}
</v-card-title>
<v-card-subtitle class="px-0 py-2 text-wrap">
{{ $t('group.ai-provider-settings.ai-providers-description') }}
</v-card-subtitle>
<GroupAIProviderSettingsEditor
v-if="group?.aiProviderSettings"
v-model="group.aiProviderSettings"
hide-header
class="mt-4"
@create="handleCreateProvider"
@update="handleUpdateProvider"
@delete="handleDeleteProvider"
/>
</v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<v-btn
variant="flat"
color="success"
:disabled="isSubmitting"
:loading="isSubmitting"
:text="$t('general.next')"
@click="onNext"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
<!-- CONFIRMATION -->
<v-stepper-window-item :value="Pages.CONFIRM">
<v-container max-width="880">
@@ -252,7 +297,11 @@ import { useLocales } from "~/composables/use-locales";
import { alert } from "~/composables/use-toast";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form";
import { useGroupSelf } from "~/composables/use-groups";
import { useAIProviders } from "~/composables/use-ai-providers";
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
definePageMeta({
layout: "blank",
@@ -267,6 +316,42 @@ const userApi = useUserApi();
const adminApi = useAdminApi();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const { group, actions: groupActions } = useGroupSelf();
const { createOne, updateOne, deleteOne } = useAIProviders();
async function handleCreateProvider(data: AIProviderCreate) {
const result = await createOne(data);
if (result.data) {
await groupActions.refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-created"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-create-failed"));
}
}
async function handleUpdateProvider(id: string, data: AIProviderUpdate) {
const result = await updateOne(id, data);
if (result.data) {
await groupActions.refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-updated"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-update-failed"));
}
}
async function handleDeleteProvider(id: string) {
const result = await deleteOne(id);
if (result.data) {
await groupActions.refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
}
}
const { locale } = useLocales();
const router = useRouter();
const isSubmitting = ref(false);
@@ -281,8 +366,9 @@ enum Pages {
LANDING = 1,
USER_INFO = 2,
PAGE_2 = 3,
CONFIRM = 4,
END = 5,
AI_PROVIDERS = 4,
CONFIRM = 5,
END = 6,
}
function getStepperColor(currentPage: Pages, page: Pages) {
@@ -475,6 +561,7 @@ async function submitAll() {
const tasks = [
submitRegistration(),
submitCommonSettings(),
groupActions.updateAIProviderSettings(),
];
await Promise.all(tasks);

View File

@@ -284,7 +284,6 @@ const appConfig = ref<CheckApp>({
isUpToDate: false,
ldapReady: false,
oidcReady: false,
enableOpenai: false,
});
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
@@ -351,15 +350,6 @@ const simpleChecks = computed<SimpleCheck[]>(() => {
color: appConfig.value.oidcReady ? goodColor : warningColor,
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
},
{
id: "openai-ready",
text: appConfig.value.enableOpenai ? i18n.t("settings.openai-ready") : i18n.t("settings.openai-not-ready"),
status: appConfig.value.enableOpenai,
errorText: i18n.t("settings.openai-ready-error-text"),
successText: i18n.t("settings.openai-ready-success-text"),
color: appConfig.value.enableOpenai ? goodColor : warningColor,
icon: appConfig.value.enableOpenai ? goodIcon : warningIcon,
},
];
return data;
});

View File

@@ -45,6 +45,7 @@
<script setup lang="ts">
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
import { useGroupSelf } from "~/composables/use-groups";
definePageMeta({
middleware: ["group-only"],
@@ -52,7 +53,8 @@ definePageMeta({
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $globals } = useNuxtApp();
const { $globals } = useNuxtApp();
const { group } = useGroupSelf();
useSeoMeta({
title: i18n.t("general.create"),
@@ -78,7 +80,7 @@ const subpages = computed<MenuItem[]>(() => [
icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-images"),
value: "image",
hide: !$appInfo.enableOpenaiImageServices,
hide: !group.value?.aiProviderSettings?.imageProviderEnabled,
},
{
icon: $globals.icons.edit,

View File

@@ -25,7 +25,7 @@
persistent-hint
/>
</v-card-text>
<v-card-text v-if="$appInfo.enableOpenai">
<v-card-text v-if="group?.aiProviderSettings?.aiEnabled">
{{ $t('recipe.recipe-debugger-use-openai-description') }}
<v-checkbox
v-model="state.useOpenAI"
@@ -69,6 +69,7 @@
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { useGroupSelf } from "~/composables/use-groups";
import { validators } from "~/composables/use-validators";
import type { Recipe } from "~/lib/api/types/recipe";
@@ -80,6 +81,7 @@ const state = reactive({
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const { group } = useGroupSelf();
const recipeUrl = computed({
set(recipe_import_url: string | null) {

View File

@@ -11,7 +11,7 @@
<v-card-text>
<v-card-text class="pa-0">
<p>{{ $t('recipe.scrape-recipe-description') }}</p>
<p v-if="$appInfo.enableOpenaiTranscriptionServices">
<p v-if="group?.aiProviderSettings?.audioProviderEnabled">
{{ $t('recipe.scrape-recipe-description-transcription') }}
</p>
</v-card-text>
@@ -145,6 +145,7 @@
<script setup lang="ts">
import type { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { useGroupSelf } from "~/composables/use-groups";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import { validators } from "~/composables/use-validators";
@@ -162,6 +163,7 @@ const auth = useMealieAuth();
const api = useUserApi();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { group } = useGroupSelf();
const router = useRouter();
const tags = useTagStore();

View File

@@ -17,25 +17,52 @@
</template>
{{ $t("profile.group-description") }}
</BasePageTitle>
<v-form ref="refGroupEditForm" @submit.prevent="handleSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
<div class="mb-10">
<v-form ref="refGroupPrefsEditForm" @submit.prevent="handlePrefsSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupPreferencesEditor v-if="group.preferences" v-model="group.preferences" />
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</div>
<div>
<v-form ref="refGroupAISettingsForm" @submit.prevent="handleAISettingsSubmit">
<v-card variant="outlined" style="border-color: lightgray;">
<v-card-text>
<GroupAIProviderSettingsEditor
v-if="group.aiProviderSettings"
v-model="group.aiProviderSettings"
@create="handleCreateProvider"
@update="handleUpdateProvider"
@delete="handleDeleteProvider"
/>
</v-card-text>
</v-card>
<div class="d-flex pa-2">
<BaseButton type="submit" edit class="ml-auto">
{{ $t("general.update") }}
</BaseButton>
</div>
</v-form>
</div>
</v-container>
</template>
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import GroupAIProviderSettingsEditor from "~/components/Domain/Group/GroupAIProviderSettingsEditor.vue";
import { useGroupSelf } from "~/composables/use-groups";
import { useAIProviders } from "~/composables/use-ai-providers";
import { alert } from "~/composables/use-toast";
import type { AIProviderCreate, AIProviderUpdate } from "~/lib/api/types/group";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
@@ -49,10 +76,11 @@ useSeoMeta({
title: i18n.t("group.group"),
});
const refGroupEditForm = ref<VForm | null>(null);
const refGroupPrefsEditForm = ref<VForm | null>(null);
const refGroupAISettingsForm = ref<VForm | null>(null);
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || !group.value?.preferences) {
async function handlePrefsSubmit() {
if (!refGroupPrefsEditForm.value?.validate() || !group.value?.preferences) {
return;
}
@@ -64,6 +92,55 @@ async function handleSubmit() {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
async function handleAISettingsSubmit() {
if (!refGroupAISettingsForm.value?.validate() || !group.value?.aiProviderSettings) {
return;
}
const data = await groupActions.updateAIProviderSettings();
if (data) {
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
const { createOne, updateOne, deleteOne } = useAIProviders();
async function handleCreateProvider(data: AIProviderCreate) {
const result = await createOne(data);
if (result.data) {
await groupActions.refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-created"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-create-failed"));
}
}
async function handleUpdateProvider(id: string, data: AIProviderUpdate) {
const result = await updateOne(id, data);
if (result.data) {
await groupActions.refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-updated"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-update-failed"));
}
}
async function handleDeleteProvider(id: string) {
const result = await deleteOne(id);
if (result.data) {
await groupActions.refresh();
alert.success(i18n.t("group.ai-provider-settings.provider-deleted"));
}
else {
alert.error(i18n.t("group.ai-provider-settings.provider-delete-failed"));
}
}
</script>
<style lang="css">

View File

@@ -45,7 +45,7 @@
sm="12"
md="12"
>
<v-card variant="outlined" style="border-color: lightgray;" class="mt-4">
<v-card variant="outlined" style="border-color: lightgray;" class="mt-4 pa-2">
<v-card-title class="text-h6 pb-0">
{{ $t('profile.household-statistics') }}
</v-card-title>

View File

@@ -0,0 +1,65 @@
/**
* v-no-autofill directive
*
* Vuetify 3 places data-* attributes on its wrapper div, not the underlying
* <input> element, so password managers still offer to autofill. This directive
* uses a MutationObserver to find and patch every <input> inside the host
* element, even ones rendered asynchronously (dialogs, conditional blocks).
*
* From: https://github.com/vuetifyjs/vuetify/issues/18202
*
* Usage:
* <v-text-field v-no-autofill ... />
* <v-form v-no-autofill>...</v-form>
* <div v-no-autofill>...</div>
*/
import type { Directive, DirectiveBinding } from "vue";
interface ObservedElement extends HTMLElement {
_noAutofillObserver?: MutationObserver;
}
function patchInput(input: HTMLInputElement) {
input.setAttribute("autocomplete", "off");
input.setAttribute("data-1p-ignore", "true");
input.setAttribute("data-lpignore", "true");
input.setAttribute("data-protonpass-ignore", "true");
input.setAttribute("data-bwignore", "true");
input.setAttribute("data-form-type", "other");
}
function patchAll(el: HTMLElement) {
if (el.tagName === "INPUT") {
patchInput(el as HTMLInputElement);
}
el.querySelectorAll<HTMLInputElement>("input").forEach(patchInput);
}
const noAutofill: Directive<ObservedElement> = {
mounted(el: ObservedElement, _binding: DirectiveBinding) {
patchAll(el);
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
patchAll(node as HTMLElement);
}
}
}
});
observer.observe(el, { childList: true, subtree: true });
el._noAutofillObserver = observer;
},
unmounted(el: ObservedElement) {
el._noAutofillObserver?.disconnect();
delete el._noAutofillObserver;
},
};
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive("no-autofill", noAutofill);
});

View File

@@ -14,6 +14,7 @@ import type BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.
import type BaseDialog from "@/components/global/BaseDialog.vue";
import type BaseDivider from "@/components/global/BaseDivider.vue";
import type BaseExpansionPanels from "@/components/global/BaseExpansionPanels.vue";
import type BaseKeyValueEditor from "@/components/global/BaseKeyValueEditor.vue";
import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import type BasePageTitle from "@/components/global/BasePageTitle.vue";
import type ButtonLink from "@/components/global/ButtonLink.vue";
@@ -54,6 +55,7 @@ declare module "vue" {
BaseDialog: typeof BaseDialog;
BaseDivider: typeof BaseDivider;
BaseExpansionPanels: typeof BaseExpansionPanels;
BaseKeyValueEditor: typeof BaseKeyValueEditor;
BaseOverflowButton: typeof BaseOverflowButton;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;

View File

@@ -0,0 +1,298 @@
"""add table for ai providers
Revision ID: 2187537c52b8
Revises: c7427796f7b6
Create Date: 2026-05-18 16:27:05.770218
"""
import sqlalchemy as sa
from alembic import op
from pydantic_settings import BaseSettings, SettingsConfigDict
import mealie.db.migration_types
from mealie.core.config import ENV
from mealie.core.root_logger import get_logger
from mealie.core.settings.settings import get_secrets_dir
from mealie.db.models._model_utils.guid import GUID
# revision identifiers, used by Alembic.
revision = "2187537c52b8"
down_revision: str | None = "c7427796f7b6"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
logger = get_logger()
class LegacyOpenAISettings(BaseSettings):
OPENAI_BASE_URL: str | None = None
OPENAI_API_KEY: str | None = None
OPENAI_MODEL: str = "gpt-4o"
OPENAI_AUDIO_MODEL: str = "whisper-1"
OPENAI_CUSTOM_HEADERS: dict[str, str] = {}
OPENAI_CUSTOM_PARAMS: dict[str, str] = {}
OPENAI_ENABLE_IMAGE_SERVICES: bool = True
OPENAI_ENABLE_TRANSCRIPTION_SERVICES: bool = True
OPENAI_REQUEST_TIMEOUT: int = 300
model_config = SettingsConfigDict(extra="ignore")
def get_openai_settings() -> LegacyOpenAISettings:
return LegacyOpenAISettings(
_env_file=ENV,
_env_file_encoding="utf-8",
_secrets_dir=get_secrets_dir(),
)
def is_postgres() -> bool:
return op.get_context().dialect.name == "postgresql"
def generate_id() -> str:
val = GUID.generate()
dialect = op.get_bind().dialect
return GUID.convert_value_to_guid(val, dialect) # type: ignore
def create_provider(settings_id: str, provider_id: str, model: str, openai_settings: LegacyOpenAISettings) -> None:
logger.info(f"Creating provider '{model}'")
conn = op.get_bind()
conn.execute(
sa.text(
"INSERT INTO ai_providers (id, settings_id, name, base_url, api_key, model, timeout) "
"VALUES (:id, :settings_id, :name, :base_url, :api_key, :model, :timeout)"
),
{
"id": provider_id,
"settings_id": settings_id,
# we only create one provider per model in this mirgration script,
# so there's no chance for collisions
"name": model,
"base_url": openai_settings.OPENAI_BASE_URL,
"api_key": openai_settings.OPENAI_API_KEY,
"model": model,
"timeout": openai_settings.OPENAI_REQUEST_TIMEOUT,
},
)
for key, value in openai_settings.OPENAI_CUSTOM_HEADERS.items():
conn.execute(
sa.text(
"INSERT INTO ai_provider_headers (provider_id, key_name, value) "
"VALUES (:provider_id, :key_name, :value)"
),
{"provider_id": provider_id, "key_name": key, "value": value},
)
for key, value in openai_settings.OPENAI_CUSTOM_PARAMS.items():
conn.execute(
sa.text(
"INSERT INTO ai_provider_params (provider_id, key_name, value) VALUES (:provider_id, :key_name, :value)"
),
{"provider_id": provider_id, "key_name": key, "value": value},
)
def create_providers() -> None:
"""Create provider settings and migrate legacy OPEN_AI_... environment variables to a provider"""
openai_settings = get_openai_settings()
create_providers = bool(openai_settings.OPENAI_API_KEY and openai_settings.OPENAI_MODEL)
if create_providers:
logger.info("Found legacy OpenAI configuration, creating new AI providers")
conn = op.get_bind()
groups = conn.execute(sa.text("SELECT id FROM groups")).fetchall()
for (group_id,) in groups:
logger.info(f"Creating provider settings for {group_id=}")
# Create AI provider settings
settings_id = generate_id()
conn.execute(
sa.text("INSERT INTO ai_provider_settings (id, group_id) VALUES (:id, :group_id)"),
{"id": settings_id, "group_id": group_id},
)
if not create_providers:
continue
# Create provider
default_provider_id = generate_id()
model = openai_settings.OPENAI_MODEL
create_provider(settings_id, default_provider_id, model, openai_settings)
# Set the image provider if image services are enabled
if openai_settings.OPENAI_ENABLE_IMAGE_SERVICES:
image_provider_id = default_provider_id
else:
image_provider_id = None
# Set the audio provider if transcription services are enabled
if openai_settings.OPENAI_ENABLE_TRANSCRIPTION_SERVICES:
transcription_model = openai_settings.OPENAI_AUDIO_MODEL or model
if transcription_model == openai_settings.OPENAI_MODEL:
audio_provider_id = default_provider_id
else:
# The transcription model is different than the base model, so create a new provider
audio_provider_id = generate_id()
create_provider(settings_id, audio_provider_id, transcription_model, openai_settings)
else:
audio_provider_id = None
# Update the provider settings to reference new provider(s)
conn.execute(
sa.text(
"""
UPDATE ai_provider_settings
SET
default_provider_id = :default_provider_id,
audio_provider_id = :audio_provider_id,
image_provider_id = :image_provider_id
WHERE id = :id
"""
),
{
"default_provider_id": default_provider_id,
"audio_provider_id": audio_provider_id,
"image_provider_id": image_provider_id,
"id": settings_id,
},
)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"ai_provider_settings",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("default_provider_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("audio_provider_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("image_provider_id", mealie.db.migration_types.GUID(), nullable=True),
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.ForeignKeyConstraint(["default_provider_id"], ["ai_providers.id"], use_alter=True),
sa.ForeignKeyConstraint(["audio_provider_id"], ["ai_providers.id"], use_alter=True),
sa.ForeignKeyConstraint(
["group_id"],
["groups.id"],
),
sa.ForeignKeyConstraint(["image_provider_id"], ["ai_providers.id"], use_alter=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("group_id", name="ai_provider_settings_group_id_key"),
)
with op.batch_alter_table("ai_provider_settings", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_ai_provider_settings_default_provider_id"), ["default_provider_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_ai_provider_settings_audio_provider_id"), ["audio_provider_id"], unique=False
)
batch_op.create_index(batch_op.f("ix_ai_provider_settings_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_ai_provider_settings_group_id"), ["group_id"], unique=False)
batch_op.create_index(
batch_op.f("ix_ai_provider_settings_image_provider_id"), ["image_provider_id"], unique=False
)
op.create_table(
"ai_providers",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("settings_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("base_url", sa.String(), nullable=True),
sa.Column("api_key", sa.String(), nullable=False),
sa.Column("model", sa.String(), nullable=False),
sa.Column("timeout", sa.Integer(), nullable=False),
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.ForeignKeyConstraint(
["settings_id"],
["ai_provider_settings.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", "settings_id", name="ai_providers_name_settings_id_key"),
)
with op.batch_alter_table("ai_providers", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_ai_providers_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_ai_providers_name"), ["name"], unique=False)
batch_op.create_index(batch_op.f("ix_ai_providers_settings_id"), ["settings_id"], unique=False)
op.create_table(
"ai_provider_headers",
sa.Column("provider_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("key_name", sa.String(), nullable=True),
sa.Column("value", sa.String(), nullable=True),
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["ai_providers.id"],
),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("ai_provider_headers", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_ai_provider_headers_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_ai_provider_headers_provider_id"), ["provider_id"], unique=False)
op.create_table(
"ai_provider_params",
sa.Column("provider_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("key_name", sa.String(), nullable=True),
sa.Column("value", sa.String(), nullable=True),
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["ai_providers.id"],
),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("ai_provider_params", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_ai_provider_params_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_ai_provider_params_provider_id"), ["provider_id"], unique=False)
# ### end Alembic commands ###
try:
with op.get_bind().begin_nested():
create_providers()
except Exception:
logger.exception("Failed to migrate legacy OpenAI config")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("ai_provider_params", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_ai_provider_params_provider_id"))
batch_op.drop_index(batch_op.f("ix_ai_provider_params_created_at"))
op.drop_table("ai_provider_params")
with op.batch_alter_table("ai_provider_headers", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_ai_provider_headers_provider_id"))
batch_op.drop_index(batch_op.f("ix_ai_provider_headers_created_at"))
op.drop_table("ai_provider_headers")
with op.batch_alter_table("ai_providers", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_ai_providers_settings_id"))
batch_op.drop_index(batch_op.f("ix_ai_providers_name"))
batch_op.drop_index(batch_op.f("ix_ai_providers_created_at"))
op.drop_table("ai_providers")
with op.batch_alter_table("ai_provider_settings", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_image_provider_id"))
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_group_id"))
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_created_at"))
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_audio_provider_id"))
batch_op.drop_index(batch_op.f("ix_ai_provider_settings_default_provider_id"))
op.drop_table("ai_provider_settings")
# ### end Alembic commands ###

View File

@@ -88,8 +88,6 @@ async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info(settings.LDAP_FEATURE)
logger.info("--------==OIDC==--------")
logger.info(settings.OIDC_FEATURE)
logger.info("-------==OPENAI==-------")
logger.info(settings.OPENAI_FEATURE)
logger.info("------------------------")
yield

View File

@@ -3,7 +3,7 @@ import os
import secrets
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any, Literal, NamedTuple
from typing import Annotated, Literal, NamedTuple
from dateutil.tz import tzlocal
from pydantic import PlainSerializer, field_validator
@@ -388,60 +388,12 @@ class AppSettings(AppLoggingSettings):
# ===============================================
# OpenAI Configuration
OPENAI_BASE_URL: str | None = None
"""The base URL for the OpenAI API. Leave this unset for most usecases"""
OPENAI_API_KEY: MaskedNoneString = None
"""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_AUDIO_MODEL: str = "whisper-1"
"""Which OpenAI model to use for audio transcription. 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_ENABLE_TRANSCRIPTION_SERVICES: bool = True
"""Whether to enable audio transcription features in OpenAI"""
OPENAI_WORKERS: int = 2
"""
Number of OpenAI workers per request. Higher values may increase
processing speed, but will incur additional API costs
"""
OPENAI_SEND_DATABASE_DATA: bool = True
"""
Sending database data may increase accuracy in certain requests,
but will incur additional API costs
"""
OPENAI_REQUEST_TIMEOUT: int = 300
"""
The number of seconds to wait for an OpenAI request to complete before cancelling the request
"""
OPENAI_CUSTOM_PROMPT_DIR: str | None = None
"""
Path to a folder containing custom prompt files;
files are individually optional, each prompt name will fall back to the default if no custom file exists
"""
@property
def OPENAI_FEATURE(self) -> FeatureDetails:
description = None
if not self.OPENAI_API_KEY:
description = "OPENAI_API_KEY is not set"
elif not self.OPENAI_MODEL:
description = "OPENAI_MODEL is not set"
return FeatureDetails(
enabled=bool(self.OPENAI_API_KEY and self.OPENAI_MODEL),
description=description,
)
@property
def OPENAI_ENABLED(self) -> bool:
"""Validates OpenAI settings are all set"""
return self.OPENAI_FEATURE.enabled
# ===============================================
# Web Concurrency

View File

@@ -1,3 +1,4 @@
from .ai_providers import *
from .exports import *
from .group import *
from .preferences import *

View File

@@ -0,0 +1,145 @@
from typing import TYPE_CHECKING
import sqlalchemy as sa
from sqlalchemy import orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .group import Group
def unwrap_headers_and_params(func):
"""Decorator function to unpack headers and params into dicts"""
def unwrap(value: dict | None) -> list[dict]:
if value is None:
value = {}
return [{"key": k, "value": v} for k, v in value.items()]
def wrapper(*args, **kwargs):
headers = kwargs.pop("request_headers", {})
params = kwargs.pop("request_params", {})
return func(
*args,
request_headers=unwrap(headers),
request_params=unwrap(params),
**kwargs,
)
return wrapper
class AIProviderKV:
"""
Template for key-value pairs
This class is not an actual table, so it does not inherit from SqlAlchemyBase
"""
id: orm.Mapped[int] = orm.mapped_column(sa.Integer, primary_key=True)
key_name: orm.Mapped[str | None] = orm.mapped_column(sa.String)
value: orm.Mapped[str | None] = orm.mapped_column(sa.String)
def __init__(self, key, value) -> None:
self.key_name = key
self.value = value
class AIProviderHeaders(AIProviderKV, SqlAlchemyBase):
__tablename__ = "ai_provider_headers"
provider_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ai_providers.id"), index=True)
class AIProviderParams(AIProviderKV, SqlAlchemyBase):
__tablename__ = "ai_provider_params"
provider_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ai_providers.id"), index=True)
class AIProvider(SqlAlchemyBase, BaseMixins):
__tablename__ = "ai_providers"
__table_args__ = (sa.UniqueConstraint("name", "settings_id", name="ai_providers_name_settings_id_key"),)
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: AssociationProxy[GUID] = association_proxy("settings", "group_id")
settings_id: orm.Mapped[GUID] = orm.mapped_column(
GUID, sa.ForeignKey("ai_provider_settings.id"), nullable=False, index=True
)
settings: orm.Mapped["AIProviderSettings"] = orm.relationship(
"AIProviderSettings", foreign_keys="[AIProvider.settings_id]", back_populates="providers"
)
name: orm.Mapped[str] = orm.mapped_column(sa.String, index=True, nullable=False)
base_url: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True)
api_key: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
model: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
timeout: orm.Mapped[int] = orm.mapped_column(sa.Integer, nullable=False, default=300)
request_headers: orm.Mapped[list[AIProviderHeaders]] = orm.relationship(
"AIProviderHeaders", cascade="all, delete-orphan"
)
request_params: orm.Mapped[list[AIProviderParams]] = orm.relationship(
"AIProviderParams", cascade="all, delete-orphan"
)
@unwrap_headers_and_params
@auto_init()
def __init__(self, **_) -> None:
pass
class AIProviderSettings(SqlAlchemyBase, BaseMixins):
__tablename__ = "ai_provider_settings"
__table_args__ = (sa.UniqueConstraint("group_id", name="ai_provider_settings_group_id_key"),)
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: orm.Mapped["Group"] = orm.relationship("Group", back_populates="ai_provider_settings")
providers: orm.Mapped[list[AIProvider]] = orm.relationship(
AIProvider,
foreign_keys="[AIProvider.settings_id]",
back_populates="settings",
uselist=True,
single_parent=True,
cascade="all, delete-orphan",
)
# Configured Providers
default_provider_id: orm.Mapped[GUID | None] = orm.mapped_column(
GUID, sa.ForeignKey("ai_providers.id", use_alter=True), nullable=True, index=True
)
default_provider: orm.Mapped[AIProvider | None] = orm.relationship(
AIProvider,
foreign_keys="[AIProviderSettings.default_provider_id]",
uselist=False,
post_update=True,
)
audio_provider_id: orm.Mapped[GUID | None] = orm.mapped_column(
GUID, sa.ForeignKey("ai_providers.id", use_alter=True), nullable=True, index=True
)
audio_provider: orm.Mapped[AIProvider | None] = orm.relationship(
AIProvider,
foreign_keys="[AIProviderSettings.audio_provider_id]",
uselist=False,
post_update=True,
)
image_provider_id: orm.Mapped[GUID | None] = orm.mapped_column(
GUID, sa.ForeignKey("ai_providers.id", use_alter=True), nullable=True, index=True
)
image_provider: orm.Mapped[AIProvider | None] = orm.relationship(
AIProvider,
foreign_keys="[AIProviderSettings.image_provider_id]",
uselist=False,
post_update=True,
)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@@ -16,6 +16,7 @@ from ..household.mealplan import GroupMealPlan
from ..household.webhooks import GroupWebhooksModel
from ..recipe.category import Category, group_to_categories
from ..server.task import ServerTaskModel
from .ai_providers import AIProviderSettings
from .preferences import GroupPreferencesModel
if TYPE_CHECKING:
@@ -41,6 +42,8 @@ class Group(SqlAlchemyBase, BaseMixins):
invite_tokens: Mapped[list[GroupInviteToken]] = orm.relationship(
GroupInviteToken, back_populates="group", cascade="all, delete-orphan"
)
# Config
preferences: Mapped[GroupPreferencesModel] = orm.relationship(
GroupPreferencesModel,
back_populates="group",
@@ -48,6 +51,13 @@ class Group(SqlAlchemyBase, BaseMixins):
single_parent=True,
cascade="all, delete-orphan",
)
ai_provider_settings: Mapped[AIProviderSettings] = orm.relationship(
AIProviderSettings,
back_populates="group",
uselist=False,
single_parent=True,
cascade="all, delete-orphan",
)
# Recipes
recipes: Mapped[list["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="group")
@@ -89,6 +99,7 @@ class Group(SqlAlchemyBase, BaseMixins):
"shopping_lists",
"cookbooks",
"preferences",
"ai_provider_settings",
"invite_tokens",
"mealplans",
"data_exports",

View File

@@ -0,0 +1,62 @@
import sqlalchemy as sa
from fastapi import HTTPException, status
from pydantic import UUID4
from mealie.db.models.group.ai_providers import AIProvider, AIProviderSettings
from mealie.schema.group.ai_providers import AIProviderCreate, AIProviderOut
from .repository_generic import GroupRepositoryGeneric
class GroupRepositoryAIProvider(GroupRepositoryGeneric[AIProviderOut, AIProvider]):
def create(self, data: AIProviderCreate | dict):
if isinstance(data, AIProviderCreate):
api_key = data.api_key
data = data.model_dump()
data["api_key"] = api_key
if not data.get("api_key"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="API key cannot be empty")
if not data.get("settings_id"):
data["settings_id"] = self.session.execute(
sa.select(AIProviderSettings.id).where(AIProviderSettings.group_id == self.group_id)
).scalar_one()
return super().create(data)
def update(self, match_value: str | int | UUID4, new_data: AIProviderCreate | dict):
if isinstance(new_data, AIProviderCreate):
new_data = new_data.model_dump()
# Merge existing API key into new data
if not new_data.get("api_key"):
existing = self.get_one(match_value)
if existing:
new_data["api_key"] = existing.api_key
return super().update(match_value, new_data)
def delete(self, value, match_key: str | None = None) -> AIProviderOut:
# Null out any settings references to this provider before deleting
self.session.execute(
sa.update(AIProviderSettings)
.where(AIProviderSettings.default_provider_id == value)
.values(default_provider_id=None)
)
self.session.execute(
sa.update(AIProviderSettings)
.where(AIProviderSettings.audio_provider_id == value)
.values(audio_provider_id=None)
)
self.session.execute(
sa.update(AIProviderSettings)
.where(AIProviderSettings.image_provider_id == value)
.values(image_provider_id=None)
)
# Delete
return super().delete(value, match_key)
def update_many(self, data):
raise NotImplementedError

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.group import Group, ReportEntryModel, ReportModel
from mealie.db.models.group.ai_providers import AIProvider, AIProviderSettings
from mealie.db.models.group.exports import GroupDataExportsModel
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.household.cookbook import CookBook
@@ -37,12 +38,14 @@ from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.repos.repository_ai_provider import GroupRepositoryAIProvider
from mealie.repos.repository_cookbooks import RepositoryCookbooks
from mealie.repos.repository_foods import RepositoryFood
from mealie.repos.repository_household import RepositoryHousehold, RepositoryHouseholdRecipes
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.repos.repository_units import RepositoryUnit
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.group.ai_providers import AIProviderOut, AIProviderSettingsOut
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.household.group_events import GroupEventNotifierOut
@@ -221,6 +224,16 @@ class AllRepositories:
def group_report_entries(self) -> GroupRepositoryGeneric[ReportEntryOut, ReportEntryModel]:
return GroupRepositoryGeneric(self.session, PK_ID, ReportEntryModel, ReportEntryOut, group_id=self.group_id)
@cached_property
def group_ai_provider_settings(self) -> GroupRepositoryGeneric[AIProviderSettingsOut, AIProviderSettings]:
return GroupRepositoryGeneric(
self.session, PK_GROUP_ID, AIProviderSettings, AIProviderSettingsOut, group_id=self.group_id
)
@cached_property
def group_ai_providers(self) -> GroupRepositoryAIProvider:
return GroupRepositoryAIProvider(self.session, PK_ID, AIProvider, AIProviderOut, group_id=self.group_id)
# ================================================================
# Household

View File

@@ -6,6 +6,7 @@ from . import (
admin_debug,
admin_email,
admin_maintenance,
admin_management_ai_providers,
admin_management_groups,
admin_management_households,
admin_management_users,
@@ -17,6 +18,7 @@ router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_management_users.router, tags=["Admin: Manage Users"])
router.include_router(admin_management_households.router, tags=["Admin: Manage Households"])
router.include_router(admin_management_groups.router, tags=["Admin: Manage Groups"])
router.include_router(admin_management_ai_providers.router)
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"])

View File

@@ -36,10 +36,6 @@ class AdminAboutController(BaseAdminController):
enable_oidc=settings.OIDC_AUTH_ENABLED,
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
enable_openai=settings.OPENAI_ENABLED,
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
enable_openai_transcription_services=settings.OPENAI_ENABLED
and settings.OPENAI_ENABLE_TRANSCRIPTION_SERVICES,
)
@router.get("/statistics", response_model=AppStatistics)
@@ -63,5 +59,4 @@ class AdminAboutController(BaseAdminController):
base_url_set=settings.BASE_URL != "http://localhost:8080",
is_up_to_date=APP_VERSION == "develop" or APP_VERSION == "nightly" or get_latest_version() == APP_VERSION,
oidc_ready=settings.OIDC_READY,
enable_openai=settings.OPENAI_ENABLED,
)

View File

@@ -3,6 +3,7 @@ import shutil
from pathlib import Path
from fastapi import APIRouter, File, UploadFile
from pydantic import UUID4
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.routes._base import BaseAdminController, controller
@@ -15,14 +16,11 @@ 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"
)
@router.post("/openai/{provider_id}", response_model=DebugResponse)
async def debug_openai(self, provider_id: UUID4, image: UploadFile | None = File(None)):
provider = self.repos.group_ai_providers.get_one(provider_id)
if not provider:
return DebugResponse(success=False, response="Provider not found")
with get_temporary_path() as temp_path:
if image:
@@ -37,7 +35,7 @@ class AdminDebugController(BaseAdminController):
local_images = None
try:
openai_service = OpenAIService()
openai_service = OpenAIService(self.repos)
prompt = openai_service.get_prompt("general.debug")
message = "Hello, checking to see if I can reach you."
@@ -45,7 +43,7 @@ class AdminDebugController(BaseAdminController):
message = f"{message} Here is an image to test with:"
response = await openai_service.get_response(
prompt, message, response_schema=OpenAIText, attachments=local_images
prompt, message, response_schema=OpenAIText, attachments=local_images, provider=provider
)
if not response:

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter
from pydantic import UUID4
from mealie.repos.repository_factory import AllRepositories
from mealie.routes._base import BaseAdminController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.group.ai_providers import (
AIProviderCreate,
AIProviderOut,
AIProviderUpdate,
)
router = APIRouter(prefix="/groups/{group_id}/ai-providers")
@controller(router)
class AdminGroupAIProviderController(BaseAdminController):
def _group_repos(self, group_id: UUID4) -> AllRepositories:
"""Return repos scoped to the target group."""
return AllRepositories(self.session, group_id=group_id, household_id=None)
def _mixins(self, group_id: UUID4) -> HttpRepo:
return HttpRepo[AIProviderCreate, AIProviderOut, AIProviderUpdate](
self._group_repos(group_id).group_ai_providers, self.logger
)
# =======================================================================
# Provider CRUD
@router.post("/providers", response_model=AIProviderOut, tags=["Admin: AI Providers"])
def create_ai_provider(self, group_id: UUID4, data: AIProviderCreate):
return self._mixins(group_id).create_one(data)
@router.get("/providers/{provider_id}", response_model=AIProviderOut, tags=["Admin: AI Providers"])
def get_ai_provider(self, group_id: UUID4, provider_id: UUID4):
return self._mixins(group_id).get_one(provider_id)
@router.put("/providers/{provider_id}", response_model=AIProviderOut, tags=["Admin: AI Providers"])
def update_ai_provider(self, group_id: UUID4, provider_id: UUID4, data: AIProviderUpdate):
return self._mixins(group_id).update_one(data, provider_id)
@router.delete("/providers/{provider_id}", response_model=AIProviderOut, tags=["Admin: AI Providers"])
def delete_ai_provider(self, group_id: UUID4, provider_id: UUID4):
return self._mixins(group_id).delete_one(provider_id)

View File

@@ -63,6 +63,11 @@ class AdminGroupManagementRoutes(BaseAdminController):
preferences = mapper(data.preferences, preferences)
group.preferences = self.repos.group_preferences.update(item_id, preferences)
if data.ai_provider_settings:
provider_settings = self.repos.group_ai_provider_settings.get_one(item_id)
provider_settings = mapper(data.ai_provider_settings, provider_settings)
group.ai_provider_settings = self.repos.group_ai_provider_settings.update(item_id, provider_settings)
if data.name not in ["", group.name]:
# only update the group if the name changed, since the name is the only field that can be updated
group.name = data.name

View File

@@ -41,9 +41,6 @@ def get_app_info(session: Session = Depends(generate_session)):
enable_oidc=settings.OIDC_READY,
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
enable_openai=settings.OPENAI_ENABLED,
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
enable_openai_transcription_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_TRANSCRIPTION_SERVICES,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
token_time=settings.TOKEN_TIME,
)

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter
from . import (
controller_group_ai_providers,
controller_group_households,
controller_group_reports,
controller_group_self_service,
@@ -11,6 +12,8 @@ from . import (
router = APIRouter()
router.include_router(controller_group_ai_providers.providers_router)
router.include_router(controller_group_ai_providers.settings_router)
router.include_router(controller_group_households.router)
router.include_router(controller_group_self_service.router)
router.include_router(controller_migrations.router)

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter
from pydantic import UUID4
from mealie.core.root_logger import get_logger
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.group.ai_providers import (
AIProviderCreate,
AIProviderOut,
AIProviderSettingsOut,
AIProviderSettingsUpdate,
AIProviderUpdate,
)
logger = get_logger()
settings_router = APIRouter(prefix="/groups/ai-providers/settings", tags=["Groups: AI Provider Settings"])
providers_router = APIRouter(prefix="/groups/ai-providers/providers", tags=["Groups: AI Providers"])
@controller(settings_router)
class GroupAIProviderSettingsController(BaseUserController):
@settings_router.get("", response_model=AIProviderSettingsOut)
def get_ai_provider_settings(self) -> AIProviderSettingsOut:
self.checks.can_manage()
return self.repos.group_ai_provider_settings.get_one(self.group_id)
@settings_router.put("", response_model=AIProviderSettingsOut)
def update_ai_provider_settings(self, settings: AIProviderSettingsUpdate) -> AIProviderSettingsOut:
self.checks.can_manage()
return self.repos.group_ai_provider_settings.update(self.group_id, settings)
@controller(providers_router)
class GroupAIProviderController(BaseUserController):
@property
def mixins(self):
return HttpRepo[AIProviderCreate, AIProviderOut, AIProviderUpdate](self.repos.group_ai_providers, self.logger)
@providers_router.post("", response_model=AIProviderOut)
def create_ai_provider(self, data: AIProviderCreate) -> AIProviderOut:
self.checks.can_manage()
return self.mixins.create_one(data)
@providers_router.get("/{provider_id}", response_model=AIProviderOut)
def get_ai_provider(self, provider_id: UUID4) -> AIProviderOut:
self.checks.can_manage()
return self.mixins.get_one(provider_id)
@providers_router.put("/{provider_id}", response_model=AIProviderOut)
def update_ai_provider(self, provider_id: UUID4, data: AIProviderUpdate) -> AIProviderOut:
self.checks.can_manage()
return self.mixins.update_one(data, provider_id)
@providers_router.delete("/{provider_id}", response_model=AIProviderOut)
def delete_ai_provider(self, provider_id: UUID4) -> AIProviderOut:
self.checks.can_manage()
return self.mixins.delete_one(provider_id)

View File

@@ -132,7 +132,7 @@ class RecipeController(BaseRecipeController):
# Debugger should produce the same result as the scraper sees before cleaning
ScraperClass = RecipeScraperOpenAI if data.use_openai else RecipeScraperPackage
try:
if scraped_data := await ScraperClass(data.url, self.translator).scrape_url():
if scraped_data := await ScraperClass(data.url, self.translator, self.repos).scrape_url():
return scraped_data.schema.data
except ForceTimeoutException as e:
raise HTTPException(
@@ -224,7 +224,7 @@ class RecipeController(BaseRecipeController):
async def run() -> None:
try:
recipe, extras = await create_from_html(url, self.translator, html, on_progress=on_progress)
recipe, extras = await create_from_html(url, self.repos, self.translator, html, on_progress=on_progress)
slug = self._finish_recipe_from_web(req, recipe, extras)
await queue.put(
ServerSentEvent(
@@ -317,7 +317,8 @@ class RecipeController(BaseRecipeController):
Optionally specify a language for it to translate the recipe to.
"""
if not (self.settings.OPENAI_ENABLED and self.settings.OPENAI_ENABLE_IMAGE_SERVICES):
ai_settings = self.group.ai_provider_settings
if not (ai_settings and ai_settings.image_provider_enabled):
raise HTTPException(
status_code=400,
detail=ErrorResponse.respond("OpenAI image services are not enabled"),

View File

@@ -21,9 +21,6 @@ class AppInfo(MealieModel):
enable_oidc: bool
oidc_redirect: bool
oidc_provider_name: str
enable_openai: bool
enable_openai_image_services: bool
enable_openai_transcription_services: bool
token_time: int
@@ -72,6 +69,5 @@ class CheckAppConfig(MealieModel):
email_ready: bool
ldap_ready: bool
oidc_ready: bool
enable_openai: bool
base_url_set: bool
is_up_to_date: bool

View File

@@ -1,4 +1,14 @@
# This file is auto-generated by gen_schema_exports.py
from .ai_providers import (
AIProviderCreate,
AIProviderOut,
AIProviderSave,
AIProviderSettingsCreate,
AIProviderSettingsOut,
AIProviderSettingsUpdate,
AIProviderSummary,
AIProviderUpdate,
)
from .group import GroupAdminUpdate
from .group_exports import GroupDataExport
from .group_migration import DataMigrationCreate, SupportedMigrations
@@ -15,5 +25,13 @@ __all__ = [
"SupportedMigrations",
"SeederConfig",
"GroupAdminUpdate",
"AIProviderCreate",
"AIProviderOut",
"AIProviderSave",
"AIProviderSettingsCreate",
"AIProviderSettingsOut",
"AIProviderSettingsUpdate",
"AIProviderSummary",
"AIProviderUpdate",
"GroupStorage",
]

View File

@@ -0,0 +1,139 @@
from typing import Any, Self
from pydantic import UUID4, ConfigDict, Field, ValidationInfo, computed_field, field_validator, model_validator
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.group.ai_providers import AIProvider, AIProviderSettings
from mealie.schema._mealie import MealieModel
class AIProviderCreate(MealieModel):
name: str
base_url: str | None = None
api_key: str = Field("", exclude=True)
model: str
timeout: int = 300
request_headers: dict[str, str] = {}
request_params: dict[str, str] = {}
@field_validator("name", "api_key", "model")
def validate_not_empty(val: str, info: ValidationInfo) -> str:
if not val:
raise ValueError(f"{info.field_name} cannot be empty")
return val
@field_validator("base_url", mode="before")
def validate_as_none(val: Any | None) -> Any | None:
return val or None
@field_validator("timeout")
def validate_non_negative_number(val: int, info: ValidationInfo) -> int:
if val < 0:
raise ValueError(f"{info.field_name} cannot be less than zero")
return val
class AIProviderSave(AIProviderCreate):
settings_id: UUID4
class AIProviderUpdate(AIProviderCreate): ...
class AIProviderOut(AIProviderCreate):
id: UUID4
model_config = ConfigDict(from_attributes=True)
@field_validator("request_headers", "request_params", mode="before")
def wrap_headers_and_params(cls, v):
if isinstance(v, dict):
return v
return {x.key_name: x.value for x in v} if v else {}
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(AIProvider.request_headers),
selectinload(AIProvider.request_params),
]
class AIProviderSummary(MealieModel):
id: UUID4
name: str
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return []
class AIProviderSettingsCreate(MealieModel):
group_id: UUID4
class AIProviderSettingsUpdate(MealieModel):
default_provider_id: UUID4 | None
audio_provider_id: UUID4 | None
image_provider_id: UUID4 | None
@field_validator("default_provider_id", "audio_provider_id", "image_provider_id", mode="before")
def validate_as_none(val: Any | None) -> Any | None:
return val or None
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
joinedload(AIProviderSettings.default_provider),
joinedload(AIProviderSettings.audio_provider),
joinedload(AIProviderSettings.image_provider),
]
class AIProviderSettingsOut(AIProviderSettingsUpdate):
providers: list[AIProviderSummary]
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def validate_providers(self) -> Self:
existing_ids = {provider.id for provider in self.providers}
for provider_id_name in ["default_provider_id", "audio_provider_id", "image_provider_id"]:
if not (val := getattr(self, provider_id_name, None)):
continue
if val not in existing_ids:
setattr(self, provider_id_name, None)
return self
@computed_field # type: ignore[misc]
@property
def ai_enabled(self) -> bool:
return self.default_provider_id is not None
@computed_field # type: ignore[misc]
@property
def audio_provider_enabled(self) -> bool:
return self.ai_enabled and self.audio_provider_id is not None
@computed_field # type: ignore[misc]
@property
def image_provider_enabled(self) -> bool:
return self.ai_enabled and self.image_provider_id is not None
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(AIProviderSettings.providers),
joinedload(AIProviderSettings.default_provider),
joinedload(AIProviderSettings.audio_provider),
joinedload(AIProviderSettings.image_provider),
]

View File

@@ -2,6 +2,7 @@ from pydantic import UUID4
from mealie.schema._mealie import MealieModel
from .ai_providers import AIProviderSettingsUpdate
from .group_preferences import UpdateGroupPreferences
@@ -9,3 +10,4 @@ class GroupAdminUpdate(MealieModel):
id: UUID4
name: str
preferences: UpdateGroupPreferences | None = None
ai_provider_settings: AIProviderSettingsUpdate | None = None

View File

@@ -13,6 +13,7 @@ from mealie.db.models.users import User
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.db.models.users.users import AuthMethod, LongLiveToken
from mealie.schema._mealie import MealieModel
from mealie.schema.group.ai_providers import AIProviderSettingsOut
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.household.webhook import CreateWebhook, ReadWebhook
from mealie.schema.response.pagination import PaginationBase
@@ -256,6 +257,7 @@ class GroupInDB(UpdateGroup):
households: list[GroupHouseholdSummary] | None = None
users: list[UserSummary] | None = None
preferences: ReadGroupPreferences | None = None
ai_provider_settings: AIProviderSettingsOut | None = None
webhooks: list[ReadWebhook] = []
model_config = ConfigDict(from_attributes=True)
@@ -297,6 +299,7 @@ class GroupSummary(GroupBase):
name: str
slug: str
preferences: ReadGroupPreferences | None = None
ai_provider_settings: AIProviderSettingsOut | None = None
@classmethod
def loader_options(cls) -> list[LoaderOption]:

View File

@@ -36,6 +36,7 @@ class GroupService(BaseService):
group_repos = get_repositories(repos.session, group_id=new_group.id, household_id=None)
group_preferences = group_repos.group_preferences.create(prefs)
group_ai_provider_settings = group_repos.group_ai_provider_settings.create({"group_id": new_group.id})
settings = get_app_settings()
household = HouseholdService.create_household(
@@ -48,6 +49,7 @@ class GroupService(BaseService):
)
new_group.preferences = group_preferences
new_group.ai_provider_settings = group_ai_provider_settings
new_group.households = [household]
return new_group

View File

@@ -1,8 +1,9 @@
from .openai import OpenAIDataInjection, OpenAIImageExternal, OpenAILocalImage, OpenAIService
from .openai import OpenAIDataInjection, OpenAIImageExternal, OpenAILocalImage, OpenAINotEnabledException, OpenAIService
__all__ = [
"OpenAIDataInjection",
"OpenAIImageExternal",
"OpenAILocalImage",
"OpenAINotEnabledException",
"OpenAIService",
]

View File

@@ -15,6 +15,8 @@ from pydantic import BaseModel, field_validator
from mealie.core import exceptions, root_logger
from mealie.core.config import get_app_settings
from mealie.pkgs import img
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.ai_providers import AIProviderOut
from mealie.schema.openai._base import OpenAIBase
from mealie.schema.openai.general import OpenAIText
@@ -24,6 +26,12 @@ T = TypeVar("T", bound=OpenAIBase)
logger = root_logger.get_logger(__name__)
class OpenAINotEnabledException(Exception):
def __init__(self, message: str = "OpenAI not enabled"):
self.message = message
super().__init__(self.message)
class OpenAIDataInjection(BaseModel):
description: str
value: str
@@ -100,27 +108,65 @@ class OpenAILocalAudio(OpenAIAttachment):
class OpenAIService(BaseService):
PROMPTS_DIR = Path(os.path.dirname(os.path.abspath(__file__))) / "prompts"
def __init__(self) -> None:
settings = get_app_settings()
if not settings.OPENAI_ENABLED:
raise ValueError("OpenAI is not enabled")
def __init__(self, repos: AllRepositories) -> None:
self.repos = repos
self.provider_settings = repos.group_ai_provider_settings.get_one(repos.group_id)
self.model = settings.OPENAI_MODEL
self.audio_model = settings.OPENAI_AUDIO_MODEL
self.workers = settings.OPENAI_WORKERS
self.send_db_data = settings.OPENAI_SEND_DATABASE_DATA
self.custom_prompt_dir = settings.OPENAI_CUSTOM_PROMPT_DIR
self.get_client = lambda: AsyncOpenAI(
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,
# Load providers
self.default_provider = (
self.repos.group_ai_providers.get_one(self.provider_settings.default_provider_id)
if self.provider_settings and self.provider_settings.default_provider_id
else None
)
self.audio_provider = (
self.repos.group_ai_providers.get_one(self.provider_settings.audio_provider_id)
if self.provider_settings and self.provider_settings.audio_provider_id
else None
)
self.image_provider = (
self.repos.group_ai_providers.get_one(self.provider_settings.image_provider_id)
if self.provider_settings and self.provider_settings.image_provider_id
else None
)
# Build client
settings = get_app_settings()
self.custom_prompt_dir = settings.OPENAI_CUSTOM_PROMPT_DIR
super().__init__()
def get_client(self, provider: AIProviderOut) -> AsyncOpenAI:
return AsyncOpenAI(
base_url=provider.base_url or None,
api_key=provider.api_key,
timeout=provider.timeout,
default_headers=provider.request_headers or None,
default_query=provider.request_params or None,
)
def _get_provider(self, attachments: list[OpenAIAttachment] | None = None) -> AIProviderOut:
"""Select the appropriate provider based on attachment types, falling back to the default."""
has_image = any(isinstance(a, OpenAIImageBase) for a in (attachments or []))
has_audio = any(isinstance(a, OpenAILocalAudio) for a in (attachments or []))
if has_image and has_audio:
raise ValueError("Cannot process both images and audio in one request")
if has_image:
if not self.image_provider:
raise OpenAINotEnabledException("No image provider set")
return self.image_provider
if has_audio:
if not self.audio_provider:
raise OpenAINotEnabledException("No audio provider set")
return self.audio_provider
else:
if not self.default_provider:
raise OpenAINotEnabledException("No default provider set")
return self.default_provider
def _get_prompt_file_candidates(self, name: str) -> list[Path]:
"""
Returns a list of prompt file path candidates.
@@ -215,8 +261,10 @@ class OpenAIService(BaseService):
)
return "\n".join(content_parts)
async def _get_raw_response(self, prompt: str, content: list[dict], response_schema: type[T]) -> ChatCompletion:
client = self.get_client()
async def _get_raw_response(
self, prompt: str, content: list[dict], response_schema: type[T], provider: AIProviderOut
) -> ChatCompletion:
client = self.get_client(provider)
return await client.chat.completions.parse(
messages=[
{
@@ -228,7 +276,7 @@ class OpenAIService(BaseService):
"content": content,
},
],
model=self.model,
model=provider.model,
response_format=response_schema,
)
@@ -239,15 +287,17 @@ class OpenAIService(BaseService):
*,
response_schema: type[T],
attachments: list[OpenAIAttachment] | None = None,
provider: AIProviderOut | None = None,
) -> T | None:
"""Send data to OpenAI and return the response message content"""
try:
user_messages = [{"type": "text", "text": message}]
provider = provider or self._get_provider(attachments)
user_messages: list[dict] = [{"type": "text", "text": message}]
for attachment in attachments or []:
user_messages.append(attachment.build_message())
response = await self._get_raw_response(prompt, user_messages, response_schema)
response = await self._get_raw_response(prompt, user_messages, response_schema, provider)
if not response.choices:
return None
@@ -259,13 +309,16 @@ class OpenAIService(BaseService):
raise Exception(f"OpenAI Request Failed. {e.__class__.__name__}: {e}") from e
async def transcribe_audio(self, audio_file_path: Path) -> str | None:
client = self.get_client()
if not self.audio_provider:
raise OpenAINotEnabledException("No audio provider set")
client = self.get_client(self.audio_provider)
# Create a transcription from the audio
try:
with open(audio_file_path, "rb") as audio_file:
transcript = await client.audio.transcriptions.create(
model=self.audio_model,
model=self.audio_provider.model,
file=audio_file,
)
return transcript.text

View File

@@ -146,13 +146,13 @@ class ABCIngredientParser(ABC):
def __init__(self, group_id: UUID4, session: Session, translator: Translator) -> None:
self.group_id = group_id
self.session = session
self.data_matcher = DataMatcher(self._repos, self.food_fuzzy_match_threshold, self.unit_fuzzy_match_threshold)
self.data_matcher = DataMatcher(self.repos, self.food_fuzzy_match_threshold, self.unit_fuzzy_match_threshold)
self.translator = translator
self.t = self.translator.t
@property
def _repos(self) -> AllRepositories:
def repos(self) -> AllRepositories:
return get_repositories(self.session, group_id=self.group_id)
@property

View File

@@ -1,4 +1,3 @@
import asyncio
import json
from rapidfuzz import fuzz
@@ -107,7 +106,7 @@ class OpenAIParser(ABCIngredientParser):
return self.find_ingredient_match(parsed_ingredient)
def _get_prompt(self, service: OpenAIService) -> str:
if service.send_db_data and self.data_matcher.units_by_alias:
if self.data_matcher.units_by_alias:
data_injections = [
OpenAIDataInjection(
description=(
@@ -125,36 +124,18 @@ class OpenAIParser(ABCIngredientParser):
return service.get_prompt("recipes.parse-recipe-ingredients", data_injections=data_injections)
@staticmethod
def _chunk_messages(messages: list[str], n=1) -> list[list[str]]:
if n < 1:
n = 1
return [messages[i : i + n] for i in range(0, len(messages), n)]
async def _parse(self, ingredients: list[str]) -> OpenAIIngredients:
service = OpenAIService()
service = OpenAIService(self.repos)
prompt = self._get_prompt(service)
# chunk ingredients and send each chunk to its own worker
ingredient_chunks = self._chunk_messages(ingredients, n=service.workers)
tasks = [
service.get_response(prompt, json.dumps(chunk, separators=(",", ":")), response_schema=OpenAIIngredients)
for chunk in ingredient_chunks
]
response = await service.get_response(
prompt, json.dumps(ingredients, separators=(",", ":")), response_schema=OpenAIIngredients
)
# re-combine chunks into one response
try:
unfiltered_responses = await asyncio.gather(*tasks)
except Exception as e:
raise Exception("Failed to call OpenAI services") from e
responses = [response for response in unfiltered_responses if response]
if not responses:
if not response:
raise Exception("No response from OpenAI")
return OpenAIIngredients(
ingredients=[ingredient for response in responses for ingredient in response.ingredients]
)
return OpenAIIngredients(ingredients=response.ingredients)
async def parse_one(self, ingredient_string: str) -> ParsedIngredient:
items = await self.parse([ingredient_string])

View File

@@ -13,7 +13,6 @@ import sqlalchemy as sa
from fastapi import UploadFile
from mealie.core import exceptions
from mealie.core.config import get_app_settings
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.lang.providers import Translator
from mealie.pkgs import cache
@@ -623,11 +622,10 @@ class OpenAIRecipeService(RecipeServiceBase):
)
async def build_recipe_from_images(self, images: list[Path], translate_language: str | None) -> Recipe:
settings = get_app_settings()
if not (settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES):
openai_service = OpenAIService(self.repos)
if not (openai_service.provider_settings and openai_service.provider_settings.image_provider_enabled):
raise ValueError("OpenAI image services are not available")
openai_service = OpenAIService()
prompt = openai_service.get_prompt("recipes.parse-recipe-image")
openai_images = [OpenAILocalImage(filename=os.path.basename(image), path=image) for image in images]

View File

@@ -85,7 +85,7 @@ class RecipeBulkScraperService(BaseService):
async def _do(url: str) -> Recipe | None:
async with sem:
try:
recipe, _ = await create_from_html(url, self.translator)
recipe, _ = await create_from_html(url, self.repos, self.translator)
return recipe
except Exception as e:
self.service.logger.error(f"failed to scrape url during bulk url import {url}")

View File

@@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable
from mealie.core.root_logger import get_logger
from mealie.lang.providers import Translator
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe
from mealie.services.scraper import cleaner
from mealie.services.scraper.scraped_extras import ScrapedExtras
@@ -31,11 +32,14 @@ class RecipeScraper:
# List of recipe scrapers. Note that order matters
scrapers: list[type[ABCScraperStrategy]]
def __init__(self, translator: Translator, scrapers: list[type[ABCScraperStrategy]] | None = None) -> None:
def __init__(
self, repos: AllRepositories, translator: Translator, scrapers: list[type[ABCScraperStrategy]] | None = None
) -> None:
if scrapers is None:
scrapers = DEFAULT_SCRAPER_STRATEGIES
self.scrapers = scrapers
self.repos = repos
self.translator = translator
self.logger = get_logger()
@@ -60,7 +64,7 @@ class RecipeScraper:
return None, None
for ScraperClass in self.scrapers:
scraper = ScraperClass(url, self.translator, raw_html=html)
scraper = ScraperClass(url, self.translator, self.repos, raw_html=html)
if not scraper.can_scrape():
self.logger.debug(f"Skipping {scraper.__class__.__name__}")
continue

View File

@@ -9,6 +9,7 @@ from slugify import slugify
from mealie.core.root_logger import get_logger
from mealie.lang.providers import Translator
from mealie.pkgs import cache
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe import Recipe
from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.scraper.scraped_extras import ScrapedExtras
@@ -24,6 +25,7 @@ class ParserErrors(StrEnum):
async def create_from_html(
url: str,
repos: AllRepositories,
translator: Translator,
html: str | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
@@ -39,7 +41,7 @@ async def create_from_html(
Returns:
Recipe: Recipe Object
"""
scraper = RecipeScraper(translator)
scraper = RecipeScraper(repos, translator)
if not html:
extracted_url = regex_search(r"(https?://|www\.)[^\s]+", url)

View File

@@ -18,11 +18,11 @@ from w3lib.html import get_base_url
from yt_dlp.extractor.generic import GenericIE
from mealie.core import exceptions
from mealie.core.config import get_app_settings
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.core.root_logger import get_logger
from mealie.lang.providers import Translator
from mealie.pkgs import safehttp
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.openai.general import OpenAIText
from mealie.schema.openai.recipe import OpenAIRecipe
from mealie.schema.recipe.recipe import Recipe, RecipeStep
@@ -145,12 +145,14 @@ class ABCScraperStrategy(ABC):
self,
url: str,
translator: Translator,
repos: AllRepositories,
raw_html: str | None = None,
) -> None:
self.logger = get_logger()
self.url = url
self.raw_html = raw_html
self.translator = translator
self.repos = repos
@abstractmethod
def can_scrape(self) -> bool: ...
@@ -344,8 +346,8 @@ class RecipeScraperOpenAI(RecipeScraperPackage):
"""
def can_scrape(self) -> bool:
settings = get_app_settings()
return settings.OPENAI_ENABLED and super().can_scrape()
settings = self.repos.group_ai_provider_settings.get_one(self.repos.group_id)
return bool(settings and settings.ai_enabled and super().can_scrape())
def extract_json_ld_data_from_html(self, soup: bs4.BeautifulSoup) -> str:
data_parts: list[str] = []
@@ -406,14 +408,10 @@ class RecipeScraperOpenAI(RecipeScraperPackage):
return "\n".join(components)
async def get_html(self, url: str) -> str:
settings = get_app_settings()
if not settings.OPENAI_ENABLED:
return ""
service = OpenAIService(self.repos)
html = self.raw_html or await safe_scrape_html(url)
text = self.format_html_to_text(html)
try:
service = OpenAIService()
prompt = service.get_prompt("recipes.scrape-recipe")
response = await service.get_response(prompt, text, response_schema=OpenAIText)
@@ -448,8 +446,8 @@ class RecipeScraperOpenAITranscription(ABCScraperStrategy):
if not self.url:
return False
settings = get_app_settings()
if not (settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_TRANSCRIPTION_SERVICES):
settings = self.repos.group_ai_provider_settings.get_one(self.repos.group_id)
if not (settings and settings.audio_provider_enabled):
return False
# Check if we can actually download something to transcribe
@@ -527,7 +525,7 @@ class RecipeScraperOpenAITranscription(ABCScraperStrategy):
self,
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[Recipe, ScrapedExtras] | tuple[None, None]:
openai_service = OpenAIService()
openai_service = OpenAIService(self.repos)
with get_temporary_path() as temp_path:
if on_progress:

View File

@@ -20,7 +20,6 @@ mp = MonkeyPatch()
mp.setenv("PRODUCTION", "True")
mp.setenv("TESTING", "True")
mp.setenv("ALLOW_SIGNUP", "True")
mp.setenv("OPENAI_API_KEY", "dummy-api-key")
from pathlib import Path
from fastapi.testclient import TestClient

View File

@@ -0,0 +1,136 @@
"""
Integration tests for admin AI provider management across groups.
"""
from fastapi.testclient import TestClient
from mealie.schema.group.ai_providers import AIProviderCreate
from tests.utils import api_routes
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
# =======================================================================
# Permissions
def test_admin_ai_provider_routes_require_admin(api_client: TestClient, unique_user: TestUser, admin_user: TestUser):
"""Non-admin users cannot access admin AI provider routes."""
group_id = unique_user.group_id
response = api_client.post(
api_routes.admin_groups_group_id_ai_providers_providers(group_id),
json={"name": random_string(), "model": "gpt-4o", "apiKey": "key"},
headers=unique_user.token,
)
assert response.status_code == 403
# =======================================================================
# Provider CRUD
def test_admin_create_ai_provider_for_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Admin can create an AI provider for any group."""
provider_name = random_string()
response = api_client.post(
api_routes.admin_groups_group_id_ai_providers_providers(unique_user.group_id),
json={"name": provider_name, "model": "gpt-4o", "apiKey": "admin-created-key"},
headers=admin_user.token,
)
assert response.status_code == 200
provider = response.json()
try:
assert provider["name"] == provider_name
assert provider["model"] == "gpt-4o"
assert "id" in provider
assert "apiKey" not in provider
finally:
unique_user.repos.group_ai_providers.delete(provider["id"])
def test_admin_get_ai_provider_for_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Admin can retrieve an AI provider from any group."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="secret")
)
try:
response = api_client.get(
api_routes.admin_groups_group_id_ai_providers_providers_provider_id(unique_user.group_id, provider.id),
headers=admin_user.token,
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(provider.id)
assert data["name"] == provider.name
finally:
unique_user.repos.group_ai_providers.delete(provider.id)
def test_admin_update_ai_provider_for_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Admin can update an AI provider in any group."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="original-key")
)
try:
new_name = random_string()
response = api_client.put(
api_routes.admin_groups_group_id_ai_providers_providers_provider_id(unique_user.group_id, provider.id),
json={"name": new_name, "model": "gpt-4-turbo", "apiKey": "updated-key"},
headers=admin_user.token,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == new_name
assert data["model"] == "gpt-4-turbo"
finally:
unique_user.repos.group_ai_providers.delete(provider.id)
def test_admin_delete_ai_provider_for_group(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Admin can delete an AI provider from any group."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="to-delete")
)
response = api_client.delete(
api_routes.admin_groups_group_id_ai_providers_providers_provider_id(unique_user.group_id, provider.id),
headers=admin_user.token,
)
assert response.status_code == 200
# Confirm gone
response = api_client.get(
api_routes.admin_groups_group_id_ai_providers_providers_provider_id(unique_user.group_id, provider.id),
headers=admin_user.token,
)
assert response.status_code == 404
def test_admin_can_manage_providers_across_groups(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Admin can create providers in a group they are not a member of."""
# Create a brand new group the admin doesn't belong to
group_name = random_string()
create_resp = api_client.post(api_routes.admin_groups, json={"name": group_name}, headers=admin_user.token)
assert create_resp.status_code == 201
foreign_group_id = create_resp.json()["id"]
try:
response = api_client.post(
api_routes.admin_groups_group_id_ai_providers_providers(foreign_group_id),
json={"name": random_string(), "model": "gpt-4o", "apiKey": "cross-group-key"},
headers=admin_user.token,
)
assert response.status_code == 200
provider_id = response.json()["id"]
# Settings should also be accessible
# Cleanup provider before deleting group
api_client.delete(
api_routes.admin_groups_group_id_ai_providers_providers_provider_id(foreign_group_id, provider_id),
headers=admin_user.token,
)
finally:
api_client.delete(api_routes.admin_groups_item_id(foreign_group_id), headers=admin_user.token)

View File

@@ -2,6 +2,7 @@ from fastapi.testclient import TestClient
from mealie.core.config import get_app_settings
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.ai_providers import AIProviderCreate, AIProviderSettingsUpdate
from mealie.schema.user.user import GroupInDB
from tests.utils import api_routes
from tests.utils.assertion_helpers import assert_ignore_keys
@@ -79,6 +80,100 @@ def test_admin_update_group(api_client: TestClient, admin_user: TestUser, unique
assert_ignore_keys(as_json["preferences"], update_payload["preferences"]) # type: ignore
def test_admin_update_group_name_only(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Updating only the name leaves preferences and ai_provider_settings untouched."""
new_name = random_string()
response = api_client.put(
api_routes.admin_groups_item_id(unique_user.group_id),
json={"id": unique_user.group_id, "name": new_name},
headers=admin_user.token,
)
assert response.status_code == 200
assert response.json()["name"] == new_name
def test_admin_update_group_ai_provider_settings(api_client: TestClient, admin_user: TestUser, unique_user: TestUser):
"""Admin can update ai_provider_settings for a group via the PUT endpoint."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
update_payload = {
"id": unique_user.group_id,
"name": unique_user.group_id, # name is required but unchanged
"aiProviderSettings": {
"defaultProviderId": str(provider.id),
"audioProviderId": str(provider.id),
"imageProviderId": str(provider.id),
},
}
response = api_client.put(
api_routes.admin_groups_item_id(unique_user.group_id),
json=update_payload,
headers=admin_user.token,
)
assert response.status_code == 200
settings = response.json()["aiProviderSettings"]
assert settings["defaultProviderId"] == str(provider.id)
assert settings["audioProviderId"] == str(provider.id)
assert settings["imageProviderId"] == str(provider.id)
finally:
# Clear provider references before deleting the provider
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
unique_user.repos.group_ai_providers.delete(provider.id)
def test_admin_update_group_ai_provider_settings_clear(
api_client: TestClient, admin_user: TestUser, unique_user: TestUser
):
"""Admin can clear ai_provider_settings provider IDs for a group."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
# First set the providers
api_client.put(
api_routes.admin_groups_item_id(unique_user.group_id),
json={
"id": unique_user.group_id,
"name": unique_user.group_id,
"aiProviderSettings": {
"defaultProviderId": str(provider.id),
"audioProviderId": None,
"imageProviderId": None,
},
},
headers=admin_user.token,
)
# Now clear them
response = api_client.put(
api_routes.admin_groups_item_id(unique_user.group_id),
json={
"id": unique_user.group_id,
"name": unique_user.group_id,
"aiProviderSettings": {
"defaultProviderId": None,
"audioProviderId": None,
"imageProviderId": None,
},
},
headers=admin_user.token,
)
assert response.status_code == 200
settings = response.json()["aiProviderSettings"]
assert settings["defaultProviderId"] is None
assert settings["audioProviderId"] is None
assert settings["imageProviderId"] is None
finally:
unique_user.repos.group_ai_providers.delete(provider.id)
def test_admin_delete_group(unfiltered_database: AllRepositories, api_client: TestClient, admin_user: TestUser):
group = unfiltered_database.groups.create({"name": random_string()})
response = api_client.delete(api_routes.admin_groups_item_id(group.id), headers=admin_user.token)

View File

@@ -0,0 +1,584 @@
"""
Integration tests for AI provider CRUD, settings, permissions, and API key security.
"""
from uuid import uuid4
from fastapi.testclient import TestClient
from mealie.schema.group.ai_providers import AIProviderCreate, AIProviderSettingsUpdate
from tests.utils import api_routes
from tests.utils.factories import random_string, user_registration_factory
from tests.utils.fixture_schemas import TestUser
# ==========================================
# Provider CRUD
# ==========================================
def test_create_provider(api_client: TestClient, unique_user: TestUser):
data = {"name": random_string(), "model": "gpt-4o", "apiKey": "test-key"}
response = api_client.post(api_routes.groups_ai_providers_providers, json=data, headers=unique_user.token)
assert response.status_code == 200
provider = response.json()
assert provider["name"] == data["name"]
assert provider["model"] == data["model"]
assert "id" in provider
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider["id"]), headers=unique_user.token)
def test_get_provider(api_client: TestClient, unique_user: TestUser):
# Create a provider first
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
response = api_client.get(
api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(provider.id)
assert data["name"] == provider.name
assert data["model"] == provider.model
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_get_provider_not_found(api_client: TestClient, unique_user: TestUser):
response = api_client.get(api_routes.groups_ai_providers_providers_provider_id(uuid4()), headers=unique_user.token)
assert response.status_code == 404
def test_update_provider(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="original-key")
)
try:
new_model = "gpt-4-turbo"
update_data = {"name": provider.name, "model": new_model, "apiKey": "updated-key"}
response = api_client.put(
api_routes.groups_ai_providers_providers_provider_id(provider.id),
json=update_data,
headers=unique_user.token,
)
assert response.status_code == 200
updated = response.json()
assert updated["model"] == new_model
assert updated["id"] == str(provider.id)
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_delete_provider(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
response = api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token
)
assert response.status_code == 200
# Verify it's gone
response = api_client.get(
api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token
)
assert response.status_code == 404
# ==========================================
# Provider Settings CRUD
# ==========================================
def test_get_settings(api_client: TestClient, unique_user: TestUser):
response = api_client.get(api_routes.groups_ai_providers_settings, headers=unique_user.token)
assert response.status_code == 200
settings = response.json()
assert "providers" in settings
assert "defaultProviderId" in settings
assert "audioProviderId" in settings
assert "imageProviderId" in settings
def test_update_settings_set_default_provider(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
update = {"defaultProviderId": str(provider.id), "audioProviderId": None, "imageProviderId": None}
response = api_client.put(api_routes.groups_ai_providers_settings, json=update, headers=unique_user.token)
assert response.status_code == 200
settings = response.json()
assert settings["defaultProviderId"] == str(provider.id)
finally:
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_update_settings_all_provider_types(api_client: TestClient, unique_user: TestUser):
default_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
audio_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="whisper-1", api_key="test-key")
)
image_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="dall-e-3", api_key="test-key")
)
try:
update = {
"defaultProviderId": str(default_provider.id),
"audioProviderId": str(audio_provider.id),
"imageProviderId": str(image_provider.id),
}
response = api_client.put(api_routes.groups_ai_providers_settings, json=update, headers=unique_user.token)
assert response.status_code == 200
settings = response.json()
assert settings["defaultProviderId"] == str(default_provider.id)
assert settings["audioProviderId"] == str(audio_provider.id)
assert settings["imageProviderId"] == str(image_provider.id)
finally:
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
for p in [default_provider, audio_provider, image_provider]:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(p.id), headers=unique_user.token)
def test_update_settings_clear_providers(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=provider.id, audio_provider_id=None, image_provider_id=None),
)
try:
# Now clear all
update = {"defaultProviderId": None, "audioProviderId": None, "imageProviderId": None}
response = api_client.put(api_routes.groups_ai_providers_settings, json=update, headers=unique_user.token)
assert response.status_code == 200
settings = response.json()
assert settings["defaultProviderId"] is None
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_settings_providers_list_populated(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
response = api_client.get(api_routes.groups_ai_providers_settings, headers=unique_user.token)
assert response.status_code == 200
provider_ids = [p["id"] for p in response.json()["providers"]]
assert str(provider.id) in provider_ids
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
# ==========================================
# Delete provider cascades to settings
# ==========================================
def test_delete_default_provider_clears_settings(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=provider.id, audio_provider_id=None, image_provider_id=None),
)
# Delete the provider
response = api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token
)
assert response.status_code == 200
# Settings should now have nulled-out default
settings = unique_user.repos.group_ai_provider_settings.get_one(unique_user.repos.group_id)
assert settings is not None
assert settings.default_provider_id is None
def test_delete_audio_provider_clears_settings(api_client: TestClient, unique_user: TestUser):
default_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
audio_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="whisper-1", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(
default_provider_id=default_provider.id,
audio_provider_id=audio_provider.id,
image_provider_id=None,
),
)
try:
# Delete only the audio provider
api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(audio_provider.id), headers=unique_user.token
)
settings = unique_user.repos.group_ai_provider_settings.get_one(unique_user.repos.group_id)
assert settings is not None
assert settings.audio_provider_id is None
assert settings.default_provider_id == default_provider.id
finally:
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(default_provider.id), headers=unique_user.token
)
def test_delete_image_provider_clears_settings(api_client: TestClient, unique_user: TestUser):
default_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
image_provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="dall-e-3", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(
default_provider_id=default_provider.id,
audio_provider_id=None,
image_provider_id=image_provider.id,
),
)
try:
# Delete only the image provider
api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(image_provider.id), headers=unique_user.token
)
settings = unique_user.repos.group_ai_provider_settings.get_one(unique_user.repos.group_id)
assert settings is not None
assert settings.image_provider_id is None
assert settings.default_provider_id == default_provider.id
finally:
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(default_provider.id), headers=unique_user.token
)
# ==========================================
# Permissions: can_manage required
# ==========================================
def test_providers_require_can_manage_get(api_client: TestClient, user_tuple: list[TestUser]):
usr, _ = user_tuple
# Ensure user does NOT have can_manage
user = usr.repos.users.get_one(usr.user_id)
assert user
user.can_manage = False
usr.repos.users.update(user.id, user)
# Create a provider so there's something to GET (using repos directly to bypass permission check)
provider = usr.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
response = api_client.get(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=usr.token)
assert response.status_code == 403
finally:
usr.repos.group_ai_providers.delete(provider.id)
def test_providers_require_can_manage_create(api_client: TestClient, user_tuple: list[TestUser]):
usr, _ = user_tuple
user = usr.repos.users.get_one(usr.user_id)
assert user
user.can_manage = False
usr.repos.users.update(user.id, user)
data = {"name": random_string(), "model": "gpt-4o", "apiKey": "test-key"}
response = api_client.post(api_routes.groups_ai_providers_providers, json=data, headers=usr.token)
assert response.status_code == 403
def test_providers_require_can_manage_update(api_client: TestClient, user_tuple: list[TestUser]):
usr, _ = user_tuple
user = usr.repos.users.get_one(usr.user_id)
assert user
user.can_manage = False
usr.repos.users.update(user.id, user)
provider = usr.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
update_data = {"name": provider.name, "model": "gpt-4-turbo", "apiKey": "key"}
response = api_client.put(
api_routes.groups_ai_providers_providers_provider_id(provider.id), json=update_data, headers=usr.token
)
assert response.status_code == 403
finally:
usr.repos.group_ai_providers.delete(provider.id)
def test_providers_require_can_manage_delete(api_client: TestClient, user_tuple: list[TestUser]):
usr, _ = user_tuple
user = usr.repos.users.get_one(usr.user_id)
assert user
user.can_manage = False
usr.repos.users.update(user.id, user)
provider = usr.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
try:
response = api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=usr.token
)
assert response.status_code == 403
finally:
usr.repos.group_ai_providers.delete(provider.id)
def test_settings_require_can_manage_get(api_client: TestClient, user_tuple: list[TestUser]):
usr, _ = user_tuple
user = usr.repos.users.get_one(usr.user_id)
assert user
user.can_manage = False
usr.repos.users.update(user.id, user)
response = api_client.get(api_routes.groups_ai_providers_settings, headers=usr.token)
assert response.status_code == 403
def test_settings_require_can_manage_update(api_client: TestClient, user_tuple: list[TestUser]):
usr, _ = user_tuple
user = usr.repos.users.get_one(usr.user_id)
assert user
user.can_manage = False
usr.repos.users.update(user.id, user)
update = {"defaultProviderId": None, "audioProviderId": None, "imageProviderId": None}
response = api_client.put(api_routes.groups_ai_providers_settings, json=update, headers=usr.token)
assert response.status_code == 403
# ==========================================
# API key not exposed in responses
# ==========================================
def test_api_key_not_in_create_response(api_client: TestClient, unique_user: TestUser):
data = {"name": random_string(), "model": "gpt-4o", "apiKey": "super-secret-key"}
response = api_client.post(api_routes.groups_ai_providers_providers, json=data, headers=unique_user.token)
assert response.status_code == 200
provider = response.json()
try:
assert "apiKey" not in provider
assert "api_key" not in provider
assert "super-secret-key" not in str(provider)
finally:
api_client.delete(
api_routes.groups_ai_providers_providers_provider_id(provider["id"]), headers=unique_user.token
)
def test_api_key_not_in_get_response(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="super-secret-key")
)
try:
response = api_client.get(
api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token
)
assert response.status_code == 200
data = response.json()
assert "apiKey" not in data
assert "api_key" not in data
assert "super-secret-key" not in str(data)
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_api_key_not_in_update_response(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="original-key")
)
try:
update_data = {"name": provider.name, "model": "gpt-4-turbo", "apiKey": "updated-secret-key"}
response = api_client.put(
api_routes.groups_ai_providers_providers_provider_id(provider.id),
json=update_data,
headers=unique_user.token,
)
assert response.status_code == 200
data = response.json()
assert "apiKey" not in data
assert "api_key" not in data
assert "updated-secret-key" not in str(data)
assert "original-key" not in str(data)
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_api_key_not_in_settings_response(api_client: TestClient, unique_user: TestUser):
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="secret-in-settings")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=provider.id, audio_provider_id=None, image_provider_id=None),
)
try:
response = api_client.get(api_routes.groups_ai_providers_settings, headers=unique_user.token)
assert response.status_code == 200
data = response.json()
assert "apiKey" not in data
assert "api_key" not in data
assert "secret-in-settings" not in str(data)
finally:
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
def test_api_key_not_in_groups_self_response(api_client: TestClient, unique_user: TestUser):
"""Ensure the groups/self endpoint does not expose any AI provider data including API keys."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="groups-self-secret")
)
try:
response = api_client.get(api_routes.groups_self, headers=unique_user.token)
assert response.status_code == 200
data = response.json()
assert "api_key" not in str(data)
assert "apiKey" not in str(data)
assert "groups-self-secret" not in str(data)
finally:
api_client.delete(api_routes.groups_ai_providers_providers_provider_id(provider.id), headers=unique_user.token)
# ==========================================
# New group creation creates empty settings singleton
# ==========================================
def test_new_group_has_empty_ai_provider_settings(api_client: TestClient):
"""When a user registers (creating a new group), empty AI provider settings are created."""
registration = user_registration_factory()
response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True))
assert response.status_code == 201
# Login
form_data = {"username": registration.email, "password": registration.password}
response = api_client.post(api_routes.auth_token, data=form_data)
assert response.status_code == 200
token = response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Fetch AI provider settings for the newly created group
response = api_client.get(api_routes.groups_ai_providers_settings, headers=headers)
assert response.status_code == 200
settings = response.json()
assert settings["defaultProviderId"] is None
assert settings["audioProviderId"] is None
assert settings["imageProviderId"] is None
assert settings["providers"] == []
def test_new_group_created_via_admin_has_empty_ai_provider_settings(
api_client: TestClient,
admin_token: dict,
):
"""When an admin creates a group, empty AI provider settings are created."""
group_name = random_string()
response = api_client.post(api_routes.admin_groups, json={"name": group_name}, headers=admin_token)
assert response.status_code == 201
group_id = response.json()["id"]
try:
# Create a user in the new group with can_manage=True
user_data = {
"fullName": random_string(),
"username": random_string(),
"email": f"{random_string()}@example.com",
"password": "useruser",
"group": group_name,
"household": "Family",
"admin": False,
"canManage": True,
"tokens": [],
}
response = api_client.post(api_routes.admin_users, json=user_data, headers=admin_token)
assert response.status_code == 201
# Login as the new user
form_data = {"username": user_data["email"], "password": "useruser"}
response = api_client.post(api_routes.auth_token, data=form_data)
assert response.status_code == 200
token = response.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
response = api_client.get(api_routes.groups_ai_providers_settings, headers=headers)
assert response.status_code == 200
settings = response.json()
assert settings["defaultProviderId"] is None
assert settings["audioProviderId"] is None
assert settings["imageProviderId"] is None
assert settings["providers"] == []
finally:
api_client.delete(api_routes.admin_groups_item_id(group_id), headers=admin_token)

View File

@@ -3,6 +3,7 @@ import json
import pytest
from fastapi.testclient import TestClient
from mealie.schema.group.ai_providers import AIProviderCreate, AIProviderSettingsUpdate
from mealie.schema.openai.recipe import (
OpenAIRecipe,
OpenAIRecipeIngredient,
@@ -15,6 +16,22 @@ from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.fixture(scope="module", autouse=True)
def setup_ai_providers(unique_user: TestUser):
"""Create AI providers for the test group so image-based OpenAI routes are enabled."""
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name="test-provider", model="gpt-4o", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(
default_provider_id=provider.id,
audio_provider_id=None,
image_provider_id=provider.id,
),
)
def test_openai_create_recipe_from_image(
api_client: TestClient,
monkeypatch: pytest.MonkeyPatch,

View File

@@ -4,7 +4,7 @@ import pytest
from fastapi.testclient import TestClient
import mealie.services.scraper.recipe_scraper as recipe_scraper_module
import mealie.services.scraper.scraper_strategies as scraper_strategies_module
from mealie.schema.group.ai_providers import AIProviderCreate, AIProviderSettingsUpdate
from mealie.schema.openai.general import OpenAIText
from mealie.services.openai import OpenAIService
from mealie.services.recipe.recipe_data_service import RecipeDataService
@@ -47,12 +47,17 @@ def recipe_url() -> str:
@pytest.fixture(autouse=True)
def openai_scraper_setup(monkeypatch: pytest.MonkeyPatch, bare_html: str):
"""Restrict to only RecipeScraperOpenAI, enable it unconditionally, and prevent real HTTP calls."""
def openai_scraper_setup(monkeypatch: pytest.MonkeyPatch, bare_html: str, unique_user: TestUser):
"""Restrict to only RecipeScraperOpenAI, create real DB provider data, and prevent real HTTP calls."""
monkeypatch.setattr(recipe_scraper_module, "DEFAULT_SCRAPER_STRATEGIES", [RecipeScraperOpenAI])
settings_stub = type("_Settings", (), {"OPENAI_ENABLED": True})()
monkeypatch.setattr(scraper_strategies_module, "get_app_settings", lambda: settings_stub)
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=provider.id, audio_provider_id=None, image_provider_id=None),
)
async def mock_safe_scrape_html(url: str) -> str:
return bare_html
@@ -171,9 +176,11 @@ def test_create_by_url_openai_disabled(
monkeypatch: pytest.MonkeyPatch,
recipe_url: str,
):
"""When OPENAI_ENABLED is False, can_scrape() returns False and the endpoint returns 400."""
disabled_settings = type("_Settings", (), {"OPENAI_ENABLED": False})()
monkeypatch.setattr(scraper_strategies_module, "get_app_settings", lambda: disabled_settings)
"""When no default provider is set, can_scrape() returns False and the endpoint returns 400."""
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
response = api_client.post(
api_routes.recipes_create_url,

View File

@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
import mealie.services.scraper.recipe_scraper as recipe_scraper_module
from mealie.core import exceptions
from mealie.core.config import get_app_settings
from mealie.schema.group.ai_providers import AIProviderCreate, AIProviderSettingsUpdate
from mealie.schema.openai.recipe import OpenAIRecipe, OpenAIRecipeIngredient, OpenAIRecipeInstruction
from mealie.services.openai import OpenAIService
from mealie.services.scraper.scraper_strategies import RecipeScraperOpenAITranscription
@@ -27,10 +27,22 @@ def _make_openai_recipe() -> OpenAIRecipe:
@pytest.fixture(autouse=True)
def video_scraper_setup(monkeypatch: pytest.MonkeyPatch):
def video_scraper_setup(monkeypatch: pytest.MonkeyPatch, unique_user: TestUser):
# Restrict to only the video scraper so other strategies don't interfere
monkeypatch.setattr(recipe_scraper_module, "DEFAULT_SCRAPER_STRATEGIES", [RecipeScraperOpenAITranscription])
provider = unique_user.repos.group_ai_providers.create(
AIProviderCreate(name=random_string(), model="gpt-4o", api_key="test-key")
)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(
default_provider_id=provider.id,
audio_provider_id=provider.id,
image_provider_id=None,
),
)
# Prevent any real HTTP calls during scraping
async def mock_safe_scrape_html(url: str) -> str:
return "<html></html>"
@@ -117,8 +129,10 @@ def test_create_recipe_from_video_transcription_disabled(
monkeypatch: pytest.MonkeyPatch,
unique_user: TestUser,
):
settings = get_app_settings()
monkeypatch.setattr(settings, "OPENAI_ENABLE_TRANSCRIPTION_SERVICES", False)
unique_user.repos.group_ai_provider_settings.update(
unique_user.repos.group_id,
AIProviderSettingsUpdate(default_provider_id=None, audio_provider_id=None, image_provider_id=None),
)
r = api_client.post(api_routes.recipes_create_url, json={"url": VIDEO_URL}, headers=unique_user.token)
assert r.status_code == 400

View File

@@ -0,0 +1,206 @@
from uuid import uuid4
import pytest
from pydantic import ValidationError
from mealie.schema.group.ai_providers import (
AIProviderCreate,
AIProviderSettingsOut,
AIProviderSummary,
)
class AIProviderCreateTests:
def test_valid_create(self):
provider = AIProviderCreate(name="test", api_key="key", model="gpt-4o")
assert provider.name == "test"
assert provider.model == "gpt-4o"
assert provider.timeout == 300
assert provider.base_url is None
@pytest.mark.parametrize("field", ["name", "api_key", "model"])
def test_empty_field_raises(self, field: str):
data: dict = {"name": "test", "api_key": "key", "model": "gpt-4o", field: ""}
with pytest.raises(ValidationError):
AIProviderCreate(**data)
@pytest.mark.parametrize("timeout", [-1, -100])
def test_negative_timeout_raises(self, timeout: int):
with pytest.raises(ValidationError):
AIProviderCreate(name="test", api_key="key", model="gpt-4o", timeout=timeout)
def test_zero_timeout_is_valid(self):
provider = AIProviderCreate(name="test", api_key="key", model="gpt-4o", timeout=0)
assert provider.timeout == 0
@pytest.mark.parametrize("base_url", ["", None])
def test_base_url_empty_becomes_none(self, base_url: str | None):
provider = AIProviderCreate(name="test", api_key="key", model="gpt-4o", base_url=base_url)
assert provider.base_url is None
def test_api_key_excluded_from_serialization(self):
provider = AIProviderCreate(name="test", api_key="secret", model="gpt-4o")
dumped = provider.model_dump()
assert "api_key" not in dumped
def test_api_key_excluded_from_json(self):
provider = AIProviderCreate(name="test", api_key="secret", model="gpt-4o")
json_str = provider.model_dump_json()
assert "api_key" not in json_str
assert "secret" not in json_str
class AIProviderSettingsOutTests:
def _make_settings(
self,
*,
default_provider_id=None,
audio_provider_id=None,
image_provider_id=None,
providers=None,
) -> AIProviderSettingsOut:
if providers is None:
providers = []
return AIProviderSettingsOut(
default_provider_id=default_provider_id,
audio_provider_id=audio_provider_id,
image_provider_id=image_provider_id,
providers=providers,
)
# --- ai_enabled ---
def test_ai_enabled_false_when_no_default(self):
s = self._make_settings()
assert not s.ai_enabled
def test_ai_enabled_true_when_default_set(self):
pid = uuid4()
s = self._make_settings(default_provider_id=pid, providers=[AIProviderSummary(id=pid, name="p")])
assert s.ai_enabled
# --- audio_provider_enabled ---
def test_audio_provider_disabled_when_no_default(self):
audio_id = uuid4()
s = self._make_settings(
audio_provider_id=audio_id,
providers=[AIProviderSummary(id=audio_id, name="audio")],
)
# audio_provider_id is valid, but validate_providers sets audio_provider_id to None
# because without default_provider_id, it would be fine; let's test audio_provider_enabled
# which requires ai_enabled to be True
assert not s.ai_enabled
assert not s.audio_provider_enabled
def test_audio_provider_disabled_when_only_default_set(self):
pid = uuid4()
s = self._make_settings(default_provider_id=pid, providers=[AIProviderSummary(id=pid, name="p")])
assert s.ai_enabled
assert not s.audio_provider_enabled
def test_audio_provider_enabled_when_both_set(self):
pid = uuid4()
audio_id = uuid4()
s = self._make_settings(
default_provider_id=pid,
audio_provider_id=audio_id,
providers=[AIProviderSummary(id=pid, name="p"), AIProviderSummary(id=audio_id, name="audio")],
)
assert s.ai_enabled
assert s.audio_provider_enabled
# --- image_provider_enabled ---
def test_image_provider_disabled_when_no_default(self):
image_id = uuid4()
s = self._make_settings(
image_provider_id=image_id,
providers=[AIProviderSummary(id=image_id, name="img")],
)
assert not s.ai_enabled
assert not s.image_provider_enabled
def test_image_provider_disabled_when_only_default_set(self):
pid = uuid4()
s = self._make_settings(default_provider_id=pid, providers=[AIProviderSummary(id=pid, name="p")])
assert s.ai_enabled
assert not s.image_provider_enabled
def test_image_provider_enabled_when_both_set(self):
pid = uuid4()
image_id = uuid4()
s = self._make_settings(
default_provider_id=pid,
image_provider_id=image_id,
providers=[AIProviderSummary(id=pid, name="p"), AIProviderSummary(id=image_id, name="img")],
)
assert s.ai_enabled
assert s.image_provider_enabled
# --- validate_providers model validator ---
def test_validate_providers_strips_unknown_default(self):
s = self._make_settings(default_provider_id=uuid4(), providers=[])
assert s.default_provider_id is None
assert not s.ai_enabled
def test_validate_providers_strips_unknown_audio(self):
pid = uuid4()
providers = [AIProviderSummary(id=pid, name="p")]
s = self._make_settings(default_provider_id=pid, audio_provider_id=uuid4(), providers=providers)
assert s.default_provider_id == pid
assert s.audio_provider_id is None
def test_validate_providers_strips_unknown_image(self):
pid = uuid4()
providers = [AIProviderSummary(id=pid, name="p")]
s = self._make_settings(default_provider_id=pid, image_provider_id=uuid4(), providers=providers)
assert s.default_provider_id == pid
assert s.image_provider_id is None
def test_validate_providers_keeps_valid_ids(self):
pid = uuid4()
audio_id = uuid4()
image_id = uuid4()
providers = [
AIProviderSummary(id=pid, name="p"),
AIProviderSummary(id=audio_id, name="audio"),
AIProviderSummary(id=image_id, name="img"),
]
s = self._make_settings(
default_provider_id=pid,
audio_provider_id=audio_id,
image_provider_id=image_id,
providers=providers,
)
assert s.default_provider_id == pid
assert s.audio_provider_id == audio_id
assert s.image_provider_id == image_id
def test_validate_providers_strips_all_if_empty_list(self):
pid = uuid4()
s = self._make_settings(
default_provider_id=pid,
audio_provider_id=uuid4(),
image_provider_id=uuid4(),
providers=[],
)
assert s.default_provider_id is None
assert s.audio_provider_id is None
assert s.image_provider_id is None
def test_validate_providers_partial_strip(self):
"""Only the IDs pointing to missing providers are stripped."""
pid = uuid4()
audio_id = uuid4()
providers = [AIProviderSummary(id=pid, name="p"), AIProviderSummary(id=audio_id, name="audio")]
s = self._make_settings(
default_provider_id=pid,
audio_provider_id=audio_id,
image_provider_id=uuid4(), # not in list → stripped
providers=providers,
)
assert s.default_provider_id == pid
assert s.audio_provider_id == audio_id
assert s.image_provider_id is None

View File

@@ -49,6 +49,12 @@ def test_openai_parser(
monkeypatch.setattr(OpenAIService, "get_response", mock_get_response)
def mock_openai_init(self, repos):
self.repos = repos
self.custom_prompt_dir = None
monkeypatch.setattr(OpenAIService, "__init__", mock_openai_init)
with session_context() as session:
loop = asyncio.get_event_loop()
parser = get_parser(RegisteredParser.openai, unique_local_group_id, session, get_locale_provider())
@@ -69,7 +75,7 @@ def test_openai_parser_sanitize_output(
parsed_ingredient_data: tuple[list[IngredientFood], list[IngredientUnit]], # required so database is populated
monkeypatch: pytest.MonkeyPatch,
):
async def mock_get_raw_response(self, prompt: str, content: list[dict], response_schema) -> MagicMock:
async def mock_get_raw_response(self, prompt: str, content: list[dict], response_schema, provider) -> MagicMock:
# Create data with null character in JSON to test preprocessing
data = OpenAIIngredients(
ingredients=[
@@ -91,6 +97,17 @@ def test_openai_parser_sanitize_output(
# Mock the raw response here since we want to make sure our service executes processing before loading the model
monkeypatch.setattr(OpenAIService, "_get_raw_response", mock_get_raw_response)
def mock_openai_init(self, repos):
from unittest.mock import MagicMock
self.repos = repos
self.custom_prompt_dir = None
self.default_provider = MagicMock()
self.audio_provider = None
self.image_provider = None
monkeypatch.setattr(OpenAIService, "__init__", mock_openai_init)
with session_context() as session:
loop = asyncio.get_event_loop()
parser = get_parser(RegisteredParser.openai, unique_local_group_id, session, get_locale_provider())

View File

@@ -49,7 +49,7 @@ def test_html_with_recipe_data():
url = "https://www.bbc.co.uk/food/recipes/healthy_pasta_bake_60759"
translator = get_locale_provider()
open_graph_strategy = RecipeScraperOpenGraph(url, translator)
open_graph_strategy = RecipeScraperOpenGraph(url, translator, None) # type: ignore[arg-type]
recipe_data = open_graph_strategy.get_recipe_fields(path.read_text())
@@ -78,7 +78,7 @@ def test_clean_scraper_preserves_notes():
html = RecipeScraperPackage.ld_json_to_html(ld_json)
scraped = scrape_html(html, org_url="https://example.com", supported_only=False)
translator = get_locale_provider()
strategy = RecipeScraperPackage("https://example.com", translator)
strategy = RecipeScraperPackage("https://example.com", translator, None) # type: ignore[arg-type]
recipe, _ = strategy.clean_scraper(scraped, "https://example.com")

View File

@@ -1,23 +1,28 @@
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
import mealie.services.openai.openai as openai_module
from mealie.services.openai.openai import OpenAIService
def _make_mock_repos() -> MagicMock:
provider_settings = MagicMock()
provider_settings.ai_enabled = True
provider_settings.default_provider_id = uuid4()
provider_settings.audio_provider_id = None
provider_settings.image_provider_id = None
repos = MagicMock()
repos.group_id = uuid4()
repos.group_ai_provider_settings.get_one.return_value = provider_settings
repos.group_ai_providers.get_one.return_value = MagicMock()
return repos
class _SettingsStub:
OPENAI_ENABLED = True
OPENAI_MODEL = "gpt-4o"
OPENAI_AUDIO_MODEL = "whisper-1"
OPENAI_WORKERS = 1
OPENAI_SEND_DATABASE_DATA = False
OPENAI_ENABLE_IMAGE_SERVICES = True
OPENAI_ENABLE_TRANSCRIPTION_SERVICES = True
OPENAI_CUSTOM_PROMPT_DIR: str | None = None
OPENAI_BASE_URL: str | None = None
OPENAI_API_KEY = "dummy"
OPENAI_REQUEST_TIMEOUT = 30
OPENAI_CUSTOM_HEADERS: dict = {}
OPENAI_CUSTOM_PARAMS: dict = {}
@pytest.fixture()
@@ -39,7 +44,7 @@ def settings_stub(tmp_path, monkeypatch):
def test_get_prompt_default_only(settings_stub):
svc = OpenAIService()
svc = OpenAIService(_make_mock_repos())
out = svc.get_prompt("recipes.parse-recipe-ingredients")
assert out == "DEFAULT PROMPT"
@@ -51,7 +56,7 @@ def test_get_prompt_custom_dir_used(settings_stub, tmp_path):
settings_stub.OPENAI_CUSTOM_PROMPT_DIR = str(custom_dir)
svc = OpenAIService()
svc = OpenAIService(_make_mock_repos())
out = svc.get_prompt("recipes.parse-recipe-ingredients")
assert out == "CUSTOM PROMPT"
@@ -62,7 +67,7 @@ def test_get_prompt_custom_empty_falls_back_to_default(settings_stub, tmp_path):
(custom_dir / "recipes" / "parse-recipe-ingredients.txt").write_text("")
settings_stub.OPENAI_CUSTOM_PROMPT_DIR = str(custom_dir)
svc = OpenAIService()
svc = OpenAIService(_make_mock_repos())
out = svc.get_prompt("recipes.parse-recipe-ingredients")
assert out == "DEFAULT PROMPT"
@@ -73,7 +78,7 @@ def test_get_prompt_raises_when_no_files(settings_stub, monkeypatch):
for p in prompts_dir.rglob("*.txt"):
p.unlink()
svc = OpenAIService()
svc = OpenAIService(_make_mock_repos())
with pytest.raises(OSError) as ei:
svc.get_prompt("recipes.parse-recipe-ingredients")
assert "Unable to load prompt" in str(ei.value)

View File

@@ -352,7 +352,6 @@ def test_oidc_settings_validation(data: OIDCValidationCase, monkeypatch: pytest.
def test_sensitive_settings_mask(monkeypatch: pytest.MonkeyPatch):
sensitive_settings = [
"LDAP_QUERY_PASSWORD",
"OPENAI_API_KEY",
"SMTP_USER",
"SMTP_PASSWORD",
"OIDC_CLIENT_SECRET",

View File

@@ -11,8 +11,6 @@ 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"
@@ -57,6 +55,10 @@ foods = "/api/foods"
"""`/api/foods`"""
foods_merge = "/api/foods/merge"
"""`/api/foods/merge`"""
groups_ai_providers_providers = "/api/groups/ai-providers/providers"
"""`/api/groups/ai-providers/providers`"""
groups_ai_providers_settings = "/api/groups/ai-providers/settings"
"""`/api/groups/ai-providers/settings`"""
groups_households = "/api/groups/households"
"""`/api/groups/households`"""
groups_labels = "/api/groups/labels"
@@ -215,6 +217,21 @@ def admin_backups_file_name_restore(file_name):
return f"{prefix}/admin/backups/{file_name}/restore"
def admin_debug_openai_provider_id(provider_id):
"""`/api/admin/debug/openai/{provider_id}`"""
return f"{prefix}/admin/debug/openai/{provider_id}"
def admin_groups_group_id_ai_providers_providers(group_id):
"""`/api/admin/groups/{group_id}/ai-providers/providers`"""
return f"{prefix}/admin/groups/{group_id}/ai-providers/providers"
def admin_groups_group_id_ai_providers_providers_provider_id(group_id, provider_id):
"""`/api/admin/groups/{group_id}/ai-providers/providers/{provider_id}`"""
return f"{prefix}/admin/groups/{group_id}/ai-providers/providers/{provider_id}"
def admin_groups_item_id(item_id):
"""`/api/admin/groups/{item_id}`"""
return f"{prefix}/admin/groups/{item_id}"
@@ -315,6 +332,11 @@ def foods_item_id(item_id):
return f"{prefix}/foods/{item_id}"
def groups_ai_providers_providers_provider_id(provider_id):
"""`/api/groups/ai-providers/providers/{provider_id}`"""
return f"{prefix}/groups/ai-providers/providers/{provider_id}"
def groups_households_household_slug(household_slug):
"""`/api/groups/households/{household_slug}`"""
return f"{prefix}/groups/households/{household_slug}"