mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-25 11:10:26 -04:00
feat: In-app AI Provider Configuration (#7650)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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>[†][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>[†][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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal file
204
frontend/app/components/Domain/Group/GroupAIProviderDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
127
frontend/app/components/global/BaseKeyValueEditor.vue
Normal file
127
frontend/app/components/global/BaseKeyValueEditor.vue
Normal 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>
|
||||
55
frontend/app/composables/use-ai-providers.ts
Normal file
55
frontend/app/composables/use-ai-providers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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."
|
||||
|
||||
27
frontend/app/lib/api/admin/admin-ai-providers.ts
Normal file
27
frontend/app/lib/api/admin/admin-ai-providers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
27
frontend/app/lib/api/user/group-ai-providers.ts
Normal file
27
frontend/app/lib/api/user/group-ai-providers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
65
frontend/app/plugins/no-autofill.client.ts
Normal file
65
frontend/app/plugins/no-autofill.client.ts
Normal 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);
|
||||
});
|
||||
2
frontend/app/types/components.d.ts
vendored
2
frontend/app/types/components.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .ai_providers import *
|
||||
from .exports import *
|
||||
from .group import *
|
||||
from .preferences import *
|
||||
|
||||
145
mealie/db/models/group/ai_providers.py
Normal file
145
mealie/db/models/group/ai_providers.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
62
mealie/repos/repository_ai_provider.py
Normal file
62
mealie/repos/repository_ai_provider.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
44
mealie/routes/admin/admin_management_ai_providers.py
Normal file
44
mealie/routes/admin/admin_management_ai_providers.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
mealie/routes/groups/controller_group_ai_providers.py
Normal file
64
mealie/routes/groups/controller_group_ai_providers.py
Normal 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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
139
mealie/schema/group/ai_providers.py
Normal file
139
mealie/schema/group/ai_providers.py
Normal 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),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
136
tests/integration_tests/admin_tests/test_admin_ai_providers.py
Normal file
136
tests/integration_tests/admin_tests/test_admin_ai_providers.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
206
tests/unit_tests/schema_tests/test_ai_providers.py
Normal file
206
tests/unit_tests/schema_tests/test_ai_providers.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user