diff --git a/README.md b/README.md index 1b8eb62..b640de4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # n8n-nodes-requesty -An n8n community node for using [Requesty](https://requesty.ai) hosted chat models in your n8n workflows. +An n8n community node for using [Requesty](https://requesty.ai) hosted chat models and image generation in your n8n workflows. Requesty is a unified AI gateway providing access to 300+ models from OpenAI, Anthropic, Google, Meta, Mistral, and more, all through a single OpenAI compatible API with intelligent routing, automatic fallbacks, and cost optimization. @@ -57,12 +57,46 @@ Every request to Requesty is tagged with `HTTP-Referer` and `X-Title` headers so These show up in your Requesty dashboard so you can break down usage by agent, environment, or team. Setting `HTTP-Referer` or `X-Title` as a custom header overrides the node defaults. +### Image Generation + +The **Requesty Image Generation** node generates images from text prompts using models available through Requesty's gateway (such as `azure/openai/gpt-image-1`). Use it in any workflow, or attach it as a tool to an AI Agent. + +By default the node outputs binary image data (previewable in the n8n output panel and usable by downstream nodes like Write Binary File or HTTP Request). Enable **Return Image URLs** to get URLs in the JSON output instead. + +#### Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| Model | `azure/openai/gpt-image-1` | The model to use for image generation | +| Prompt | (required) | A text description of the desired image | +| Size | `1024x1024` | Image dimensions: `1024x1024`, `1536x1024`, or `1024x1536` | +| Quality | `auto` | Image quality: `auto`, `high`, `medium`, or `low` | +| Number of Images | 1 | How many images to generate (1–10) | +| Background | `auto` | Background type: `auto`, `transparent`, or `opaque` | +| Output Format | `png` | File format: `png`, `jpeg`, or `webp` | +| Return Image URLs | off | Return URLs instead of binary image data | +| Base URL | (gateway) | Override the gateway URL for self hosted deployments | +| Custom Headers | (none) | Extra HTTP headers for tagging and tracking | + +#### Using as an AI Agent Tool + +The node can be used as an AI Agent tool: + +1. Ensure your n8n instance has the environment variable `N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true` +2. Add the **Requesty Image Generation** node to your workflow +3. Connect it to the AI Agent's **Tools** input +4. The agent decides when and how to generate images based on user requests + +When used as a tool, consider enabling **Return Image URLs** so the agent receives URLs it can reference in its response. + + ### Key Features - **300+ Models**: Access models from OpenAI, Anthropic, Google, Meta, Mistral, Cohere, and more - **Responses API**: Built on the Responses API, unlocking richer capabilities than plain chat completions - **Structured Output**: Enforce a strict JSON Schema server side (real structured output, not prompt engineered) - **Native Web Search**: Let the model search the web for current information +- **Image Generation**: Generate images from text prompts, usable as a regular node or as an AI Agent tool - **Reasoning Control**: Tune reasoning effort for reasoning capable models - **Custom Headers**: Tag and track workflows with `X-Requesty-Agent`, `X-Requesty-Environment`, `X-Requesty-Team`, and more - **Intelligent Routing**: Automatic fallbacks and load balancing across providers diff --git a/__tests__/ImageGenRequesty.test.ts b/__tests__/ImageGenRequesty.test.ts new file mode 100644 index 0000000..5964755 --- /dev/null +++ b/__tests__/ImageGenRequesty.test.ts @@ -0,0 +1,278 @@ +import { ImageGenRequesty } from '../nodes/ImageGenRequesty/ImageGenRequesty.node'; + +type MockContext = { + getInputData: () => Array<{ json: Record }>; + getCredentials: () => Promise>; + getNode: () => { name: string }; + getNodeParameter: (name: string, i?: number, fallback?: unknown) => unknown; + helpers: { + httpRequestWithAuthentication: jest.Mock; + prepareBinaryData: jest.Mock; + }; + continueOnFail: () => boolean; +}; + +/** + * Builds a minimal IExecuteFunctions-like context with a single input item. + * `params` holds the node parameters (model, prompt, options). + */ +function makeContext(params: Record, httpResponse: unknown): MockContext { + return { + getInputData: () => [{ json: {} }], + getCredentials: async () => ({ apiKey: 'test-key', baseUrl: '' }), + getNode: () => ({ name: 'Requesty Image Generation' }), + getNodeParameter: (name: string, _i?: number, fallback?: unknown) => params[name] ?? fallback, + helpers: { + httpRequestWithAuthentication: jest.fn().mockResolvedValue(httpResponse), + prepareBinaryData: jest.fn().mockResolvedValue({ + data: 'base64data', + mimeType: 'image/png', + fileName: 'image_0.png', + }), + }, + continueOnFail: () => false, + }; +} + +async function run(ctx: MockContext) { + const node = new ImageGenRequesty(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await node.execute.call(ctx as any); +} + +const B64_RESPONSE = { + data: [{ b64_json: Buffer.from('fake-image').toString('base64') }], +}; + +describe('ImageGenRequesty', () => { + describe('node description', () => { + it('declares the expected identity and main input/output', () => { + const node = new ImageGenRequesty(); + expect(node.description).toMatchObject({ + displayName: 'Requesty Image Generation', + name: 'imageGenRequesty', + group: ['transform'], + version: [1], + usableAsTool: true, + }); + expect(node.description.inputs).toEqual(['main']); + expect(node.description.outputs).toEqual(['main']); + }); + + it('requires the requestyApi credential', () => { + const node = new ImageGenRequesty(); + expect(node.description.credentials).toEqual([{ name: 'requestyApi', required: true }]); + }); + + it('has model, prompt, and options properties', () => { + const node = new ImageGenRequesty(); + const propNames = node.description.properties.map((p) => p.name); + expect(propNames).toEqual(['model', 'prompt', 'options']); + }); + }); + + describe('execute', () => { + it('calls the image generation endpoint with the authenticated helper', async () => { + const ctx = makeContext( + { model: 'azure/openai/gpt-image-1', prompt: 'A cute cat', options: {} }, + B64_RESPONSE, + ); + + await run(ctx); + + expect(ctx.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'requestyApi', + expect.objectContaining({ + method: 'POST', + url: 'https://router.requesty.ai/v1/images/generations', + body: expect.objectContaining({ + model: 'azure/openai/gpt-image-1', + prompt: 'A cute cat', + response_format: 'b64_json', + }), + }), + ); + }); + + it('returns binary data by default', async () => { + const ctx = makeContext( + { model: 'azure/openai/gpt-image-1', prompt: 'A cute cat', options: {} }, + B64_RESPONSE, + ); + + const result = await run(ctx); + + expect(result[0]![0]!.binary?.data).toBeDefined(); + expect(ctx.helpers.prepareBinaryData).toHaveBeenCalledWith( + expect.any(Buffer), + 'image_0.png', + 'image/png', + ); + }); + + it('returns URLs when returnImageUrls is true', async () => { + const ctx = makeContext( + { + model: 'azure/openai/gpt-image-1', + prompt: 'A cute cat', + options: { returnImageUrls: true }, + }, + { + data: [{ url: 'https://example.com/image.png', revised_prompt: 'A cute cat sitting' }], + }, + ); + + const result = await run(ctx); + + expect(ctx.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'requestyApi', + expect.objectContaining({ + body: expect.objectContaining({ response_format: 'url' }), + }), + ); + expect(result[0]![0]!.json).toEqual({ + url: 'https://example.com/image.png', + revised_prompt: 'A cute cat sitting', + model: 'azure/openai/gpt-image-1', + }); + expect(result[0]![0]!.binary).toBeUndefined(); + }); + + it('passes optional parameters when set', async () => { + const ctx = makeContext( + { + model: 'azure/openai/gpt-image-1', + prompt: 'A logo', + options: { + size: '1536x1024', + quality: 'high', + background: 'transparent', + output_format: 'webp', + n: 2, + }, + }, + { + data: [ + { b64_json: Buffer.from('img1').toString('base64') }, + { b64_json: Buffer.from('img2').toString('base64') }, + ], + }, + ); + + const result = await run(ctx); + + expect(ctx.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'requestyApi', + expect.objectContaining({ + body: expect.objectContaining({ + size: '1536x1024', + quality: 'high', + background: 'transparent', + output_format: 'webp', + n: 2, + }), + }), + ); + expect(result[0]).toHaveLength(2); + expect(ctx.helpers.prepareBinaryData).toHaveBeenCalledWith( + expect.any(Buffer), + 'image_1.webp', + 'image/webp', + ); + }); + + it('omits default-valued optional parameters from the request body', async () => { + const ctx = makeContext( + { + model: 'azure/openai/gpt-image-1', + prompt: 'A cat', + options: { quality: 'auto', background: 'auto', output_format: 'png', n: 1 }, + }, + B64_RESPONSE, + ); + + await run(ctx); + + const body = ctx.helpers.httpRequestWithAuthentication.mock.calls[0]![1].body as Record< + string, + unknown + >; + expect(body.quality).toBeUndefined(); + expect(body.background).toBeUndefined(); + expect(body.output_format).toBeUndefined(); + expect(body.n).toBeUndefined(); + }); + + it('sends user custom headers', async () => { + const ctx = makeContext( + { + model: 'azure/openai/gpt-image-1', + prompt: 'A cute cat', + options: { + customHeaders: { header: [{ name: 'X-Requesty-Agent', value: 'my-image-bot' }] }, + }, + }, + B64_RESPONSE, + ); + + await run(ctx); + + expect(ctx.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'requestyApi', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Requesty-Agent': 'my-image-bot' }), + }), + ); + }); + + it('uses the base URL from options over the credential', async () => { + const ctx = makeContext( + { + model: 'azure/openai/gpt-image-1', + prompt: 'A cute cat', + options: { baseUrl: 'https://my-gateway.example.com/v1' }, + }, + B64_RESPONSE, + ); + + await run(ctx); + + expect(ctx.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith( + 'requestyApi', + expect.objectContaining({ + url: 'https://my-gateway.example.com/v1/images/generations', + }), + ); + }); + + it('throws when the prompt is empty', async () => { + const ctx = makeContext( + { model: 'azure/openai/gpt-image-1', prompt: '', options: {} }, + B64_RESPONSE, + ); + + await expect(run(ctx)).rejects.toThrow('The Prompt parameter is required.'); + }); + + it('throws on an unexpected response shape', async () => { + const ctx = makeContext( + { model: 'azure/openai/gpt-image-1', prompt: 'A cat', options: {} }, + { unexpected: true }, + ); + + await expect(run(ctx)).rejects.toThrow('Unexpected response format'); + }); + + it('returns the error per item when continueOnFail is enabled', async () => { + const ctx = makeContext( + { model: 'azure/openai/gpt-image-1', prompt: '', options: {} }, + B64_RESPONSE, + ); + ctx.continueOnFail = () => true; + + const result = await run(ctx); + + expect(result[0]![0]!.json).toEqual({ error: 'The Prompt parameter is required.' }); + }); + }); +}); diff --git a/credentials/RequestyApi.credentials.ts b/credentials/RequestyApi.credentials.ts index 69d2e8b..5d4e219 100644 --- a/credentials/RequestyApi.credentials.ts +++ b/credentials/RequestyApi.credentials.ts @@ -49,9 +49,17 @@ export class RequestyApi implements ICredentialType { ): Promise { requestOptions.headers ??= {}; requestOptions.headers['Authorization'] = `Bearer ${credentials.apiKey}`; + // Attribution headers identifying traffic from the n8n Requesty community node. - requestOptions.headers['HTTP-Referer'] = 'https://github.com/requestyai/n8n-requesty'; - requestOptions.headers['X-Title'] = 'n8n Requesty Community Node'; + // Only set when absent (case-insensitively) so node-level custom headers can + // override them. + const existing = new Set(Object.keys(requestOptions.headers).map((k) => k.toLowerCase())); + if (!existing.has('http-referer')) { + requestOptions.headers['HTTP-Referer'] = 'https://github.com/requestyai/n8n-requesty'; + } + if (!existing.has('x-title')) { + requestOptions.headers['X-Title'] = 'n8n Requesty Community Node'; + } return requestOptions; } } diff --git a/nodes/ImageGenRequesty/ImageGenRequesty.node.ts b/nodes/ImageGenRequesty/ImageGenRequesty.node.ts new file mode 100644 index 0000000..7c4c4eb --- /dev/null +++ b/nodes/ImageGenRequesty/ImageGenRequesty.node.ts @@ -0,0 +1,342 @@ +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestOptions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; + +import { buildHeaders, type CustomHeader } from '../shared/headers'; + +type ImageOptions = { + background?: 'auto' | 'transparent' | 'opaque'; + baseUrl?: string; + customHeaders?: { header?: CustomHeader[] }; + n?: number; + output_format?: 'png' | 'jpeg' | 'webp'; + quality?: 'auto' | 'high' | 'medium' | 'low'; + returnImageUrls?: boolean; + size?: string; +}; + +type ImageGenerationResponse = { + data?: Array<{ url?: string; b64_json?: string; revised_prompt?: string }>; +}; + +const MIME_TYPES: Record = { + png: 'image/png', + jpeg: 'image/jpeg', + webp: 'image/webp', +}; + +export class ImageGenRequesty implements INodeType { + description: INodeTypeDescription = { + displayName: 'Requesty Image Generation', + name: 'imageGenRequesty', + icon: 'file:../../icons/requesty.svg', + group: ['transform'], + version: [1], + description: + 'Generate images from text prompts using AI models through the Requesty unified gateway', + subtitle: '={{$parameter["model"]}}', + usableAsTool: true, + defaults: { + name: 'Requesty Image Generation', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Miscellaneous'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.requesty.ai/api-reference/endpoint/images-generations-create', + }, + ], + }, + }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'requestyApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Model', + name: 'model', + type: 'string', + default: 'azure/openai/gpt-image-1', + placeholder: 'e.g. azure/openai/gpt-image-1', + description: + 'The model to use for image generation, e.g. azure/openai/gpt-image-1 or azure/openai/gpt-image-1.5', + required: true, + }, + { + displayName: 'Prompt', + name: 'prompt', + type: 'string', + default: '', + placeholder: 'e.g. A watercolor painting of a Japanese garden in autumn', + description: 'A text description of the desired image to generate', + required: true, + typeOptions: { + rows: 3, + }, + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + description: 'Additional options for image generation', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Background', + name: 'background', + type: 'options', + default: 'auto', + description: + 'The background type for the generated image. Use transparent for logos, icons, and design assets.', + options: [ + { name: 'Auto', value: 'auto' }, + { + name: 'Transparent', + value: 'transparent', + description: 'Generate with a transparent background (useful for logos and icons)', + }, + { name: 'Opaque', value: 'opaque' }, + ], + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: '', + placeholder: 'https://router.requesty.ai/v1', + description: + 'Override the gateway URL for this node. Leave empty to use the URL from the credential (which defaults to the public Requesty gateway).', + }, + { + displayName: 'Custom Headers', + name: 'customHeaders', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + default: {}, + placeholder: 'Add Header', + description: + 'Extra HTTP headers sent with every request to Requesty. Use Requesty attribution headers such as X-Requesty-Agent, X-Requesty-Environment or X-Requesty-Team to tag and track this workflow. Setting HTTP-Referer or X-Title here overrides the node defaults.', + options: [ + { + name: 'header', + displayName: 'Header', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'X-Requesty-Agent', + description: 'The header name, e.g. X-Requesty-Agent', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'my-image-bot', + description: 'The header value', + }, + ], + }, + ], + }, + { + displayName: 'Number of Images', + name: 'n', + type: 'number', + default: 1, + typeOptions: { minValue: 1, maxValue: 10 }, + description: 'The number of images to generate', + }, + { + displayName: 'Output Format', + name: 'output_format', + type: 'options', + default: 'png', + description: 'The file format of the generated image', + options: [ + { name: 'PNG', value: 'png' }, + { name: 'JPEG', value: 'jpeg' }, + { name: 'WebP', value: 'webp' }, + ], + }, + { + displayName: 'Quality', + name: 'quality', + type: 'options', + default: 'auto', + description: 'The quality of the generated image', + options: [ + { name: 'Auto', value: 'auto' }, + { name: 'High', value: 'high' }, + { name: 'Medium', value: 'medium' }, + { name: 'Low', value: 'low' }, + ], + }, + { + displayName: 'Return Image URLs', + name: 'returnImageUrls', + type: 'boolean', + default: false, + description: + 'Whether to return image URLs instead of binary data. When used as an AI Agent tool, URLs are more useful since the LLM receives the JSON output.', + }, + { + displayName: 'Size', + name: 'size', + type: 'options', + default: '1024x1024', + description: 'The size of the generated images', + options: [ + { name: '1024×1024', value: '1024x1024' }, + { name: '1536×1024 (Landscape)', value: '1536x1024' }, + { name: '1024×1536 (Portrait)', value: '1024x1536' }, + ], + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const credentials = await this.getCredentials('requestyApi'); + const model = this.getNodeParameter('model', i) as string; + const prompt = this.getNodeParameter('prompt', i) as string; + const options = this.getNodeParameter('options', i, {}) as ImageOptions; + + if (!prompt) { + throw new NodeOperationError(this.getNode(), 'The Prompt parameter is required.', { + itemIndex: i, + }); + } + + const baseUrl = + options.baseUrl || (credentials.baseUrl as string) || 'https://router.requesty.ai/v1'; + + const returnUrls = options.returnImageUrls === true; + + const body: IDataObject = { + model, + prompt, + response_format: returnUrls ? 'url' : 'b64_json', + }; + + if (options.size) body.size = options.size; + if (options.quality && options.quality !== 'auto') body.quality = options.quality; + if (options.n && options.n > 1) body.n = options.n; + if (options.background && options.background !== 'auto') + body.background = options.background; + if (options.output_format && options.output_format !== 'png') + body.output_format = options.output_format; + + const headers = buildHeaders(options.customHeaders); + + const requestOptions: IHttpRequestOptions = { + method: 'POST', + url: `${baseUrl}/images/generations`, + body, + headers, + json: true, + }; + + const response = (await this.helpers.httpRequestWithAuthentication.call( + this, + 'requestyApi', + requestOptions, + )) as ImageGenerationResponse; + + if (!response.data || !Array.isArray(response.data)) { + throw new NodeOperationError( + this.getNode(), + 'Unexpected response format from image generation API.', + { itemIndex: i }, + ); + } + + if (returnUrls) { + for (const entry of response.data) { + returnData.push({ + json: { + url: entry.url, + revised_prompt: entry.revised_prompt, + model, + }, + pairedItem: { item: i }, + }); + } + } else { + const outputFormat = options.output_format ?? 'png'; + const mimeType = MIME_TYPES[outputFormat] ?? 'image/png'; + + for (let j = 0; j < response.data.length; j++) { + const entry = response.data[j]; + if (!entry?.b64_json) { + throw new NodeOperationError( + this.getNode(), + 'Expected base64 image data in the response but got none.', + { itemIndex: i }, + ); + } + + const buffer = Buffer.from(entry.b64_json, 'base64'); + const binaryData = await this.helpers.prepareBinaryData( + buffer, + `image_${j}.${outputFormat}`, + mimeType, + ); + + returnData.push({ + json: { + revised_prompt: entry.revised_prompt, + model, + }, + binary: { + data: binaryData, + }, + pairedItem: { item: i }, + }); + } + } + } catch (error) { + const nodeError = + error instanceof NodeOperationError || error instanceof NodeApiError + ? error + : new NodeApiError(this.getNode(), error as JsonObject, { itemIndex: i }); + + if (this.continueOnFail()) { + returnData.push({ + json: { error: nodeError.message }, + pairedItem: { item: i }, + }); + continue; + } + throw nodeError; + } + } + + return [returnData]; + } +} diff --git a/nodes/LmChatRequesty/LmChatRequesty.node.ts b/nodes/LmChatRequesty/LmChatRequesty.node.ts index e6ce920..278ccb8 100644 --- a/nodes/LmChatRequesty/LmChatRequesty.node.ts +++ b/nodes/LmChatRequesty/LmChatRequesty.node.ts @@ -2,7 +2,7 @@ import type { INodeType, INodeTypeDescription, ISupplyDataFunctions } from 'n8n- import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { supplyModel, type ProviderTool } from '@n8n/ai-node-sdk'; -type CustomHeader = { name?: string; value?: string }; +import { buildHeaders, type CustomHeader } from '../shared/headers'; type ModelOptions = { baseUrl?: string; @@ -17,37 +17,6 @@ type ModelOptions = { customHeaders?: { header?: CustomHeader[] }; }; -// Attribution headers identifying traffic as coming from the n8n Requesty -// community node. Always sent; can be overridden by user-supplied custom headers. -const ATTRIBUTION_HEADERS: Record = { - 'HTTP-Referer': 'https://github.com/requestyai/n8n-requesty', - 'X-Title': 'n8n Requesty Community Node', -}; - -/** - * Merges the default attribution headers with any user-supplied custom headers. - * User headers take precedence on name collision (case-insensitively), so a user - * can override the defaults. Empty names/values are skipped. - */ -function buildHeaders(customHeaders?: { header?: CustomHeader[] }): Record { - const headers: Record = { ...ATTRIBUTION_HEADERS }; - - for (const entry of customHeaders?.header ?? []) { - const name = entry.name?.trim(); - if (!name || entry.value === undefined || entry.value === '') continue; - - // Drop any default whose name matches case-insensitively, then set the - // user's header with their exact casing. - const lower = name.toLowerCase(); - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === lower) delete headers[key]; - } - headers[name] = entry.value; - } - - return headers; -} - export class LmChatRequesty implements INodeType { description: INodeTypeDescription = { displayName: 'Requesty Chat Model', diff --git a/nodes/shared/headers.ts b/nodes/shared/headers.ts new file mode 100644 index 0000000..4f9870a --- /dev/null +++ b/nodes/shared/headers.ts @@ -0,0 +1,32 @@ +export type CustomHeader = { name?: string; value?: string }; + +// Attribution headers identifying traffic as coming from the n8n Requesty +// community node. Always sent; can be overridden by user-supplied custom headers. +export const ATTRIBUTION_HEADERS: Record = { + 'HTTP-Referer': 'https://github.com/requestyai/n8n-requesty', + 'X-Title': 'n8n Requesty Community Node', +}; + +/** + * Merges the default attribution headers with any user-supplied custom headers. + * User headers take precedence on name collision (case-insensitively), so a user + * can override the defaults. Empty names/values are skipped. + */ +export function buildHeaders(customHeaders?: { header?: CustomHeader[] }): Record { + const headers: Record = { ...ATTRIBUTION_HEADERS }; + + for (const entry of customHeaders?.header ?? []) { + const name = entry.name?.trim(); + if (!name || entry.value === undefined || entry.value === '') continue; + + // Drop any default whose name matches case-insensitively, then set the + // user's header with their exact casing. + const lower = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) delete headers[key]; + } + headers[name] = entry.value; + } + + return headers; +} diff --git a/package.json b/package.json index 810642a..2785a9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@requesty/n8n-nodes-requesty", - "version": "1.0.2", + "version": "1.1.0", "description": "n8n community node for Requesty: access 300+ AI models through one unified gateway with structured output, web search and reasoning.", "license": "MIT", "homepage": "https://requesty.ai", @@ -37,7 +37,8 @@ "dist/credentials/RequestyApi.credentials.js" ], "nodes": [ - "dist/nodes/LmChatRequesty/LmChatRequesty.node.js" + "dist/nodes/LmChatRequesty/LmChatRequesty.node.js", + "dist/nodes/ImageGenRequesty/ImageGenRequesty.node.js" ] }, "devDependencies": { diff --git a/test-workflow-image.json b/test-workflow-image.json new file mode 100644 index 0000000..ba22681 --- /dev/null +++ b/test-workflow-image.json @@ -0,0 +1,106 @@ +{ + "name": "Requesty Image Generation Test — Direct · URLs · Agent Tool", + "nodes": [ + { + "parameters": {}, + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d401", + "name": "When clicking Test workflow", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-100, 380] + }, + { + "parameters": { + "model": "azure/openai/gpt-image-1", + "prompt": "A watercolor painting of a Japanese garden in autumn", + "options": { + "size": "1024x1024", + "quality": "low" + } + }, + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d402", + "name": "Generate Image Binary", + "type": "CUSTOM.imageGenRequesty", + "typeVersion": 1, + "position": [220, 160] + }, + { + "parameters": { + "model": "azure/openai/gpt-image-1", + "prompt": "A minimalist flat logo of a paper plane, transparent background", + "options": { + "returnImageUrls": true, + "background": "transparent", + "quality": "low" + } + }, + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d403", + "name": "Generate Image URLs", + "type": "CUSTOM.imageGenRequesty", + "typeVersion": 1, + "position": [220, 380] + }, + { + "parameters": { + "promptType": "define", + "text": "Generate an image of a cute robot watering a plant, then tell me the URL of the generated image.", + "options": { + "systemMessage": "You are a helpful assistant. Use the image generation tool whenever the user asks for an image." + } + }, + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d404", + "name": "Agent with Image Tool", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "position": [260, 620] + }, + { + "parameters": { + "model": "openai-responses/gpt-5.4" + }, + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d405", + "name": "Requesty Agent Model", + "type": "CUSTOM.lmChatRequesty", + "typeVersion": 1, + "position": [180, 840] + }, + { + "parameters": { + "model": "azure/openai/gpt-image-1", + "prompt": "={{ $fromAI('prompt', 'A text description of the image to generate') }}", + "options": { + "returnImageUrls": true, + "quality": "low" + } + }, + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d406", + "name": "Image Tool", + "type": "CUSTOM.imageGenRequestyTool", + "typeVersion": 1, + "position": [400, 840] + } + ], + "connections": { + "When clicking Test workflow": { + "main": [ + [ + { "node": "Generate Image Binary", "type": "main", "index": 0 }, + { "node": "Generate Image URLs", "type": "main", "index": 0 }, + { "node": "Agent with Image Tool", "type": "main", "index": 0 } + ] + ] + }, + "Requesty Agent Model": { + "ai_languageModel": [ + [{ "node": "Agent with Image Tool", "type": "ai_languageModel", "index": 0 }] + ] + }, + "Image Tool": { + "ai_tool": [[{ "node": "Agent with Image Tool", "type": "ai_tool", "index": 0 }]] + } + }, + "active": false, + "settings": { "executionOrder": "v1" }, + "pinData": {}, + "meta": { "templateId": "requesty-image-test" } +}