Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
278 changes: 278 additions & 0 deletions __tests__/ImageGenRequesty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { ImageGenRequesty } from '../nodes/ImageGenRequesty/ImageGenRequesty.node';

type MockContext = {
getInputData: () => Array<{ json: Record<string, unknown> }>;
getCredentials: () => Promise<Record<string, unknown>>;
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<string, unknown>, 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.' });
});
});
});
12 changes: 10 additions & 2 deletions credentials/RequestyApi.credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@ export class RequestyApi implements ICredentialType {
): Promise<IHttpRequestOptions> {
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;
}
}
Loading
Loading