From 2737c0ba668aa50ba8908ce51e2fb1f9e38f8eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Kinet?= Date: Tue, 23 Sep 2025 01:35:18 +0200 Subject: [PATCH] feat: Add OpenAI-compatible API support to AI Writer operation --- packages/ai-writer-operation/README.md | 16 +++++- packages/ai-writer-operation/package.json | 4 +- .../src/Provider/OpenAi.ts | 35 +++++++++++-- .../src/Provider/ProviderFactory.ts | 4 +- packages/ai-writer-operation/src/api.ts | 3 ++ packages/ai-writer-operation/src/app.ts | 50 +++++++++++++++++++ 6 files changed, 102 insertions(+), 10 deletions(-) diff --git a/packages/ai-writer-operation/README.md b/packages/ai-writer-operation/README.md index c4ca6552..dd4354ba 100644 --- a/packages/ai-writer-operation/README.md +++ b/packages/ai-writer-operation/README.md @@ -1,6 +1,6 @@ # AI Writer Operation -Generate text based on a written prompt within Directus Flows with this custom operation, powered by [OpenAI's Text Generation API]([https://.com](https://openai.com/product)), [Anthropic](https://www.anthropic.com/) [MistralAi (via Replicate)](https://replicate.com/mistralai/mistral-7b-v0.1) and [Meta's LLama (via Replicate)](https://replicate.com/meta/meta-llama-3.1-405b-instruct). +Generate text based on a written prompt within Directus Flows with this custom operation, powered by [OpenAI's Text Generation API](https://openai.com/product), [Anthropic](https://www.anthropic.com/), [MistralAi (via Replicate)](https://replicate.com/mistralai/mistral-7b-v0.1), [Meta's LLama (via Replicate)](https://replicate.com/meta/meta-llama-3.1-405b-instruct), and any OpenAI-compatible API. ![The AI Writer operation, showing a masked OpenAI API Key field, model and prompt selection fields, and a multiline text input.](https://raw.githubusercontent.com/directus-labs/extensions/main/packages/ai-writer-operation/docs/options.png) @@ -10,11 +10,23 @@ This operation contains some configuration options - an Api-Key, a selection of ![The output showing a string that has been grammatically fixed.](https://raw.githubusercontent.com/directus-labs/extensions/main/packages/ai-writer-operation/docs/output.png) ### API-Keys -You can generate your API-Keys on the follosing sites: +You can generate your API-Keys on the following sites: - [OpenAI](https://platform.openai.com/api-keys) - [Anthropic](https://console.anthropic.com/settings/workspaces/default/keys) - [Replicate](https://replicate.com/account/api-tokens) +### OpenAI-Compatible APIs +The extension now supports any OpenAI-compatible API by selecting "OpenAI Compatible (Custom)" as the AI Provider. This allows you to use services like: +- Local models (Ollama, LM Studio, etc.) +- Cloud providers (Azure OpenAI, Google Vertex AI, etc.) +- Other OpenAI-compatible services (Groq, Together AI, etc.) + +When using a custom provider, you'll need to: +1. Select "OpenAI Compatible (Custom)" as the AI Provider +2. Enter your custom API endpoint (e.g., `https://api.example.com/v1`) +3. Provide your API key +4. Choose a model from the dropdown or select "Custom Model" to enter a specific model name + ## Custom Prompts For a completely custom prompt using the "Create custom prompt" type, you will need to create a **system** message at the start of the message thread so that the Text Generation API knows how it should respond. Examples of initial system prompts can be found in the config objects of each built-in prompt in the [source code of this extension](https://github.com/directus-labs/extension-ai-writer-operation/tree/production/src/prompts). OpenAI also provides a solid overview of [how to write good prompts](https://platform.openai.com/docs/guides/prompt-engineering). diff --git a/packages/ai-writer-operation/package.json b/packages/ai-writer-operation/package.json index bc6318a9..e641a398 100644 --- a/packages/ai-writer-operation/package.json +++ b/packages/ai-writer-operation/package.json @@ -47,7 +47,9 @@ "urls": [ "https://api.openai.com/v1/**", "https://api.anthropic.com/v1/**", - "https://api.replicate.com/v1/**" + "https://api.replicate.com/v1/**", + "https://**/v1/**", + "http://**/v1/**" ] }, "sleep": {} diff --git a/packages/ai-writer-operation/src/Provider/OpenAi.ts b/packages/ai-writer-operation/src/Provider/OpenAi.ts index 1f1ade80..d7c58733 100644 --- a/packages/ai-writer-operation/src/Provider/OpenAi.ts +++ b/packages/ai-writer-operation/src/Provider/OpenAi.ts @@ -6,18 +6,43 @@ import { Provider } from './Provider'; export class OpenAi extends Provider { constructor(options: AiWriterOperationOptions) { - if (!options.apiKeyOpenAi) { - throw new InvalidPayloadError({ reason: 'OpenAI API Key is missing' }); - } + // Determine if this is a custom OpenAI-compatible provider or standard OpenAI + const isCustomProvider = options.aiProvider === 'openai-compatible'; + + if (isCustomProvider) { + if (!options.apiKeyCustom) { + throw new InvalidPayloadError({ reason: 'Custom API Key is missing' }); + } + + if (!options.customEndpoint) { + throw new InvalidPayloadError({ reason: 'Custom Endpoint is missing' }); + } + + // Ensure the endpoint ends with /chat/completions for OpenAI-compatible APIs + const endpoint = options.customEndpoint.endsWith('/chat/completions') + ? options.customEndpoint + : `${options.customEndpoint.replace(/\/$/, '')}/chat/completions`; - super(options, 'https://api.openai.com/v1/chat/completions', options.apiKeyOpenAi); + super(options, endpoint, options.apiKeyCustom); + } else { + if (!options.apiKeyOpenAi) { + throw new InvalidPayloadError({ reason: 'OpenAI API Key is missing' }); + } + + super(options, 'https://api.openai.com/v1/chat/completions', options.apiKeyOpenAi); + } } public async messageRequest(): Promise { const messages = this.getMessages(); + // Use custom model name if provided, otherwise use the selected model + const modelName = this.options.model === 'custom' && this.options.customModelName + ? this.options.customModelName + : this.options.model!; + const requestBody: RequestBody = { - model: this.options.model!, + model: modelName, messages, max_completion_tokens: this.options.maxToken || 0, }; diff --git a/packages/ai-writer-operation/src/Provider/ProviderFactory.ts b/packages/ai-writer-operation/src/Provider/ProviderFactory.ts index fc80c8fd..aeeb857c 100644 --- a/packages/ai-writer-operation/src/Provider/ProviderFactory.ts +++ b/packages/ai-writer-operation/src/Provider/ProviderFactory.ts @@ -13,7 +13,7 @@ export function getProvider(options: AiWriterOperationOptions) { return new Anthropic(options); } - if (options.aiProvider.toLowerCase() === 'openai') { + if (options.aiProvider.toLowerCase() === 'openai' || options.aiProvider.toLowerCase() === 'openai-compatible') { return new OpenAi(options); } @@ -21,5 +21,5 @@ export function getProvider(options: AiWriterOperationOptions) { return new Replicate(options); } - throw new Error(`Unsoported AI Provider ${options.aiProvider}`); + throw new Error(`Unsupported AI Provider ${options.aiProvider}`); } diff --git a/packages/ai-writer-operation/src/api.ts b/packages/ai-writer-operation/src/api.ts index 2841d93f..bdd607ac 100644 --- a/packages/ai-writer-operation/src/api.ts +++ b/packages/ai-writer-operation/src/api.ts @@ -9,7 +9,10 @@ export interface AiWriterOperationOptions { apiKeyAnthropic?: string | null; apiKeyOpenAi?: string | null; apiKeyReplicate?: string | null; + apiKeyCustom?: string | null; + customEndpoint?: string | null; model?: string | null; + customModelName?: string | null; promptKey?: string | null; system?: string | null; json_mode?: boolean; diff --git a/packages/ai-writer-operation/src/app.ts b/packages/ai-writer-operation/src/app.ts index 6f7c0934..17df3d2e 100644 --- a/packages/ai-writer-operation/src/app.ts +++ b/packages/ai-writer-operation/src/app.ts @@ -76,6 +76,14 @@ export default defineOperationApp({ return replicateModels; } + if (provider === 'openai-compatible') { + // For custom providers, allow any model name to be entered + return [ + { text: 'Custom Model (enter model name below)', value: 'custom' }, + ...openAiModels, // Include OpenAI models as examples + ]; + } + return []; }; @@ -98,6 +106,10 @@ export default defineOperationApp({ text: 'Open AI', value: 'openai', }, + { + text: 'OpenAI Compatible (Custom)', + value: 'openai-compatible', + }, { text: 'Replicate (Meta & Mistral)', value: 'replicate', @@ -148,6 +160,32 @@ export default defineOperationApp({ hidden: context.aiProvider !== 'replicate', }, }, + { + field: 'apiKeyCustom', + name: 'Custom API Key', + type: 'string', + meta: { + required: context.aiProvider === 'openai-compatible', + options: { + masked: true, + }, + width: 'full', + interface: 'input', + hidden: context.aiProvider !== 'openai-compatible', + }, + }, + { + field: 'customEndpoint', + name: 'Custom Endpoint', + type: 'string', + meta: { + required: context.aiProvider === 'openai-compatible', + width: 'full', + interface: 'input', + hidden: context.aiProvider !== 'openai-compatible', + note: 'Enter the base URL of your OpenAI-compatible API (e.g., https://api.example.com/v1). The /chat/completions endpoint will be automatically appended.', + }, + }, { field: 'model', name: 'AI Model', @@ -161,6 +199,18 @@ export default defineOperationApp({ width: 'half', }, }, + { + field: 'customModelName', + name: 'Custom Model Name', + type: 'string', + meta: { + required: context.aiProvider === 'openai-compatible' && context.model === 'custom', + width: 'half', + interface: 'input', + hidden: !(context.aiProvider === 'openai-compatible' && context.model === 'custom'), + note: 'Enter the exact model name as expected by your API provider.', + }, + }, { field: 'maxToken', name: 'Max Token',