From 2e661689604bc8175ea10be637494712dc6982f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:05:10 +0000 Subject: [PATCH 1/3] Initial plan From 873118293959013c625dd5915012b92c79f7b46e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:22:03 +0000 Subject: [PATCH 2/3] feat: Add Pollinations API integration with nova-fast as default model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POLLINATIONS_API_KEY / POLLINATIONS_BASE_URL to .env.example - Replace active custom endpoint examples in librechat.example.yaml with Pollinations (nova-fast default); old examples commented out - packages/api/src/pollinations/classify.ts: intent classification + image-prompt enhancement via nova-fast - packages/api/src/pollinations/service.ts: orchestration layer โ€” text โ†’ nova-fast, image โ†’ enhance+zimage, video โ†’ placeholder - packages/api/src/pollinations/index.ts: public exports - packages/api/src/pollinations/classify.spec.ts: Jest tests for all intent paths, error cases, and multi-turn conversation - packages/api/src/index.ts: export Pollinations module - README.md: Pollinations integration section with setup instructions Agent-Logs-Url: https://github.com/verdjs/LibreChat/sessions/ace58aa9-0e14-4a38-9814-baa691114088 Co-authored-by: verdjs <225778361+verdjs@users.noreply.github.com> --- .env.example | 9 + README.md | 28 ++ librechat.example.yaml | 178 ++++++------- packages/api/src/index.ts | 2 + .../api/src/pollinations/classify.spec.ts | 244 ++++++++++++++++++ packages/api/src/pollinations/classify.ts | 81 ++++++ packages/api/src/pollinations/index.ts | 11 + packages/api/src/pollinations/service.ts | 128 +++++++++ 8 files changed, 585 insertions(+), 96 deletions(-) create mode 100644 packages/api/src/pollinations/classify.spec.ts create mode 100644 packages/api/src/pollinations/classify.ts create mode 100644 packages/api/src/pollinations/index.ts create mode 100644 packages/api/src/pollinations/service.ts diff --git a/.env.example b/.env.example index db09bb471f7..cfcca874f3c 100644 --- a/.env.example +++ b/.env.example @@ -251,6 +251,15 @@ GOOGLE_KEY=user_provided # Vertex AI model for image generation (defaults to gemini-2.5-flash-image) # GEMINI_IMAGE_MODEL=gemini-2.5-flash-image +#===================# +# Pollinations API # +#===================# + +# Pollinations is the primary generation backend for text, image, and video. +# Get your API key at: https://enter.pollinations.ai +POLLINATIONS_API_KEY=your_pollinations_api_key +# POLLINATIONS_BASE_URL=https://gen.pollinations.ai + #============# # OpenAI # #============# diff --git a/README.md b/README.md index a7f68d9a920..254cdb33446 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,34 @@ Open source, actively developed, and built for anyone who values control over th --- +## ๐ŸŒธ Pollinations Integration + +LibreChat ships with **[Pollinations](https://pollinations.ai)** as its primary generation backend. +Every message is automatically routed through a lightweight intent classifier before reaching the model: + +| Intent | Flow | +|--------|------| +| **Text** | Classified by `nova-fast` โ†’ answered by `nova-fast` (or any model you choose) | +| **Image** | Classified by `nova-fast` โ†’ prompt enhanced by `nova-fast` โ†’ image generated with `zimage` | +| **Video** | Classified by `nova-fast` โ†’ _coming soon_ placeholder returned | + +### Setup + +1. Get a free API key at . +2. Add it to your `.env`: + ``` + POLLINATIONS_API_KEY=your_pollinations_api_key + ``` +3. Copy `librechat.example.yaml` โ†’ `librechat.yaml` (or use `CONFIG_PATH`). The **Pollinations** endpoint is already the first entry under `endpoints.custom`. + +### Available models (sample) + +`nova-fast` ยท `openai` ยท `openai-fast` ยท `openai-large` ยท `gemini-fast` ยท `claude-fast` ยท `deepseek` ยท `mistral` ยท `qwen-coder` ยท `grok` ยท `perplexity-fast` ยท `kimi` โ€” plus many more. Set `fetch: true` in the YAML to auto-populate all current models from the Pollinations `/v1/models` endpoint. + +> **Key:** Use an `sk_` prefixed secret key for server-side requests. `pk_` keys work client-side but are rate-limited. + +--- + ## ๐ŸŒ Resources **GitHub Repo:** diff --git a/librechat.example.yaml b/librechat.example.yaml index 03bb5f5bc25..904a0e1967a 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -336,114 +336,100 @@ endpoints: # # deploymentName: claude-3-5-haiku@20241022 # Override for this model custom: - # Groq Example - - name: 'groq' - apiKey: '${GROQ_API_KEY}' - baseURL: 'https://api.groq.com/openai/v1/' + # Pollinations โ€” primary generation backend (text, image, video) + # Get your API key at: https://enter.pollinations.ai + # Docs: https://gen.pollinations.ai/api/docs + - name: 'Pollinations' + apiKey: '${POLLINATIONS_API_KEY}' + baseURL: 'https://gen.pollinations.ai' models: default: - - 'llama3-70b-8192' - - 'llama3-8b-8192' - - 'llama2-70b-4096' - - 'mixtral-8x7b-32768' - - 'gemma-7b-it' - fetch: false + - 'nova-fast' + - 'openai' + - 'openai-fast' + - 'openai-large' + - 'claude-fast' + - 'claude' + - 'gemini' + - 'gemini-fast' + - 'deepseek' + - 'mistral' + - 'qwen-coder' + - 'nova' + - 'grok' + - 'perplexity-fast' + - 'kimi' + fetch: true titleConvo: true - titleModel: 'mixtral-8x7b-32768' - modelDisplayLabel: 'groq' - - # Mistral AI Example - - name: 'Mistral' # Unique name for the endpoint - # For `apiKey` and `baseURL`, you can use environment variables that you define. - # recommended environment variables: - apiKey: '${MISTRAL_API_KEY}' - baseURL: 'https://api.mistral.ai/v1' - - # Models configuration - models: - # List of default models to use. At least one value is required. - default: ['mistral-tiny', 'mistral-small', 'mistral-medium'] - # Fetch option: Set to true to fetch models from API. - fetch: true # Defaults to false. - - # Optional configurations - - # Title Conversation setting - titleConvo: true # Set to true to enable title conversation - - # Title Method: Choose between "completion" or "functions". - # titleMethod: "completion" # Defaults to "completion" if omitted. - - # Title Model: Specify the model to use for titles. - titleModel: 'mistral-tiny' # Defaults to "gpt-3.5-turbo" if omitted. - - # Summarize setting: Set to true to enable summarization. - # summarize: false - - # Summary Model: Specify the model to use if summarization is enabled. - # summaryModel: "mistral-tiny" # Defaults to "gpt-3.5-turbo" if omitted. + titleModel: 'nova-fast' + modelDisplayLabel: 'Pollinations' + iconURL: 'https://pollinations.ai/favicon.ico' - # The label displayed for the AI model in messages. - modelDisplayLabel: 'Mistral' # Default is "AI" when not set. + # --------------------------------------------------------------- + # The following custom endpoint examples are disabled by default. + # Pollinations (above) is the primary configured backend. + # Uncomment any section below if you need that specific provider. + # --------------------------------------------------------------- - # Add additional parameters to the request. Default params will be overwritten. - # addParams: - # safe_prompt: true # This field is specific to Mistral AI: https://docs.mistral.ai/api/ + # Groq Example + # - name: 'groq' + # apiKey: '${GROQ_API_KEY}' + # baseURL: 'https://api.groq.com/openai/v1/' + # models: + # default: ['llama3-70b-8192', 'llama3-8b-8192', 'mixtral-8x7b-32768'] + # fetch: false + # titleConvo: true + # titleModel: 'mixtral-8x7b-32768' + # modelDisplayLabel: 'groq' - # Drop Default params parameters from the request. See default params in guide linked below. - # NOTE: For Mistral, it is necessary to drop the following parameters or you will encounter a 422 Error: - dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty'] + # Mistral AI Example + # - name: 'Mistral' + # apiKey: '${MISTRAL_API_KEY}' + # baseURL: 'https://api.mistral.ai/v1' + # models: + # default: ['mistral-tiny', 'mistral-small', 'mistral-medium'] + # fetch: true + # titleConvo: true + # titleModel: 'mistral-tiny' + # modelDisplayLabel: 'Mistral' + # dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty'] # OpenRouter Example - - name: 'OpenRouter' - # For `apiKey` and `baseURL`, you can use environment variables that you define. - # recommended environment variables: - apiKey: '${OPENROUTER_KEY}' - baseURL: 'https://openrouter.ai/api/v1' - headers: - x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' - models: - default: ['meta-llama/llama-3-70b-instruct'] - fetch: true - titleConvo: true - titleModel: 'meta-llama/llama-3-70b-instruct' - # Recommended: Drop the stop parameter from the request as Openrouter models use a variety of stop tokens. - dropParams: ['stop'] - modelDisplayLabel: 'OpenRouter' + # - name: 'OpenRouter' + # apiKey: '${OPENROUTER_KEY}' + # baseURL: 'https://openrouter.ai/api/v1' + # models: + # default: ['meta-llama/llama-3-70b-instruct'] + # fetch: true + # titleConvo: true + # titleModel: 'meta-llama/llama-3-70b-instruct' + # dropParams: ['stop'] + # modelDisplayLabel: 'OpenRouter' # Helicone Example - - name: 'Helicone' - # For `apiKey` and `baseURL`, you can use environment variables that you define. - # recommended environment variables: - apiKey: '${HELICONE_KEY}' - baseURL: 'https://ai-gateway.helicone.ai' - headers: - x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' - models: - default: - ['gpt-4o-mini', 'claude-4.5-sonnet', 'llama-3.1-8b-instruct', 'gemini-2.5-flash-lite'] - fetch: true - titleConvo: true - titleModel: 'gpt-4o-mini' - modelDisplayLabel: 'Helicone' - iconURL: https://marketing-assets-helicone.s3.us-west-2.amazonaws.com/helicone.png + # - name: 'Helicone' + # apiKey: '${HELICONE_KEY}' + # baseURL: 'https://ai-gateway.helicone.ai' + # models: + # default: ['gpt-4o-mini', 'gemini-2.5-flash-lite'] + # fetch: true + # titleConvo: true + # titleModel: 'gpt-4o-mini' + # modelDisplayLabel: 'Helicone' # Portkey AI Example - - name: 'Portkey' - apiKey: 'dummy' - baseURL: 'https://api.portkey.ai/v1' - headers: - x-portkey-api-key: '${PORTKEY_API_KEY}' - x-portkey-virtual-key: '${PORTKEY_OPENAI_VIRTUAL_KEY}' - models: - default: ['gpt-4o-mini', 'gpt-4o', 'chatgpt-4o-latest'] - fetch: true - titleConvo: true - titleModel: 'current_model' - summarize: false - summaryModel: 'current_model' - modelDisplayLabel: 'Portkey' - iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf + # - name: 'Portkey' + # apiKey: 'dummy' + # baseURL: 'https://api.portkey.ai/v1' + # headers: + # x-portkey-api-key: '${PORTKEY_API_KEY}' + # x-portkey-virtual-key: '${PORTKEY_OPENAI_VIRTUAL_KEY}' + # models: + # default: ['gpt-4o-mini', 'gpt-4o'] + # fetch: true + # titleConvo: true + # titleModel: 'current_model' + # modelDisplayLabel: 'Portkey' # AWS Bedrock Example # Note: Bedrock endpoint is configured via environment variables diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d4b6ac95422..c8766a47aba 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -39,6 +39,8 @@ export * from './agents'; export * from './prompts'; /* Endpoints */ export * from './endpoints'; +/* Pollinations */ +export * from './pollinations'; /* Files */ export * from './files'; /* Storage */ diff --git a/packages/api/src/pollinations/classify.spec.ts b/packages/api/src/pollinations/classify.spec.ts new file mode 100644 index 00000000000..da710cb68b3 --- /dev/null +++ b/packages/api/src/pollinations/classify.spec.ts @@ -0,0 +1,244 @@ +import axios from 'axios'; + +jest.mock('axios'); + +import { classifyIntent, enhanceImagePrompt } from './classify'; +import { processPollinationsRequest, DEFAULT_TEXT_MODEL, IMAGE_MODEL } from './service'; + +const mockedAxios = axios as jest.Mocked; + +const TEST_API_KEY = 'sk-test-key'; +const TEST_MESSAGE = 'Hello, tell me a joke'; +const TEST_IMAGE_MESSAGE = 'Draw a cat in space'; +const TEST_VIDEO_MESSAGE = 'Make a video of a sunset'; + +function makeChatResponse(content: string) { + return { data: { choices: [{ message: { content } }] } }; +} + +function makeImageResponse(url: string) { + return { data: { data: [{ url }] } }; +} + +function setupAxiosClientMock(): jest.Mock { + const mockClientPost = jest.fn(); + mockedAxios.create.mockReturnValue({ post: mockClientPost } as ReturnType); + return mockClientPost; +} + +// โ”€โ”€โ”€ classifyIntent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('classifyIntent', () => { + let mockClientPost: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientPost = setupAxiosClientMock(); + }); + + it('returns "text" for a general text message', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('text')); + const result = await classifyIntent(TEST_MESSAGE, TEST_API_KEY); + expect(result).toBe('text'); + }); + + it('returns "image" when model responds with "image"', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('image')); + const result = await classifyIntent(TEST_IMAGE_MESSAGE, TEST_API_KEY); + expect(result).toBe('image'); + }); + + it('returns "video" when model responds with "video"', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('video')); + const result = await classifyIntent(TEST_VIDEO_MESSAGE, TEST_API_KEY); + expect(result).toBe('video'); + }); + + it('defaults to "text" for unrecognised model output', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('unknown-intent')); + const result = await classifyIntent(TEST_MESSAGE, TEST_API_KEY); + expect(result).toBe('text'); + }); + + it('calls Pollinations /v1/chat/completions with nova-fast model', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('text')); + await classifyIntent(TEST_MESSAGE, TEST_API_KEY); + + expect(mockClientPost).toHaveBeenCalledWith( + '/v1/chat/completions', + expect.objectContaining({ model: 'nova-fast' }), + ); + }); + + it('trims and lowercases the model response', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse(' IMAGE ')); + const result = await classifyIntent(TEST_IMAGE_MESSAGE, TEST_API_KEY); + expect(result).toBe('image'); + }); +}); + +// โ”€โ”€โ”€ enhanceImagePrompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('enhanceImagePrompt', () => { + let mockClientPost: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientPost = setupAxiosClientMock(); + }); + + it('returns the enhanced prompt from the model', async () => { + const enhanced = 'A majestic cat floating in a vivid, star-filled outer space background'; + mockClientPost.mockResolvedValueOnce(makeChatResponse(enhanced)); + const result = await enhanceImagePrompt(TEST_IMAGE_MESSAGE, TEST_API_KEY); + expect(result).toBe(enhanced); + }); + + it('falls back to the original message when model returns empty content', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('')); + const result = await enhanceImagePrompt(TEST_IMAGE_MESSAGE, TEST_API_KEY); + expect(result).toBe(TEST_IMAGE_MESSAGE); + }); + + it('calls Pollinations /v1/chat/completions with nova-fast model', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('enhanced prompt')); + await enhanceImagePrompt(TEST_IMAGE_MESSAGE, TEST_API_KEY); + + expect(mockClientPost).toHaveBeenCalledWith( + '/v1/chat/completions', + expect.objectContaining({ model: 'nova-fast' }), + ); + }); +}); + +// โ”€โ”€โ”€ processPollinationsRequest โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe('processPollinationsRequest', () => { + let mockClientPost: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientPost = setupAxiosClientMock(); + }); + + it('routes text intent to nova-fast and returns text result', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('text')); + mockedAxios.post.mockResolvedValueOnce({ + data: { choices: [{ message: { content: 'A joke!' } }] }, + }); + + const result = await processPollinationsRequest({ + userMessage: TEST_MESSAGE, + apiKey: TEST_API_KEY, + }); + + expect(result.type).toBe('text'); + if (result.type === 'text') { + expect(result.content).toBe('A joke!'); + expect(result.model).toBe(DEFAULT_TEXT_MODEL); // 'nova-fast' + } + }); + + it('respects caller-supplied model override for text intent', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('text')); + mockedAxios.post.mockResolvedValueOnce({ + data: { choices: [{ message: { content: 'Hi' } }] }, + }); + + const result = await processPollinationsRequest({ + userMessage: TEST_MESSAGE, + apiKey: TEST_API_KEY, + model: 'openai-large', + }); + + expect(result.type).toBe('text'); + if (result.type === 'text') { + expect(result.model).toBe('openai-large'); + } + }); + + it('routes image intent: enhances prompt then calls /v1/images/generations with zimage', async () => { + const enhanced = 'A detailed cosmic cat'; + mockClientPost + .mockResolvedValueOnce(makeChatResponse('image')) // classify + .mockResolvedValueOnce(makeChatResponse(enhanced)); // enhance + mockedAxios.post.mockResolvedValueOnce(makeImageResponse('https://cdn.pollinations.ai/cat.jpg')); + + const result = await processPollinationsRequest({ + userMessage: TEST_IMAGE_MESSAGE, + apiKey: TEST_API_KEY, + }); + + expect(result.type).toBe('image'); + if (result.type === 'image') { + expect(result.enhancedPrompt).toBe(enhanced); + expect(result.url).toBe('https://cdn.pollinations.ai/cat.jpg'); + } + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/v1/images/generations'), + expect.objectContaining({ model: IMAGE_MODEL, prompt: enhanced }), + expect.any(Object), + ); + }); + + it('throws when image generation returns no URL', async () => { + const enhanced = 'A detailed cosmic cat'; + mockClientPost + .mockResolvedValueOnce(makeChatResponse('image')) + .mockResolvedValueOnce(makeChatResponse(enhanced)); + mockedAxios.post.mockResolvedValueOnce({ data: { data: [] } }); + + await expect( + processPollinationsRequest({ userMessage: TEST_IMAGE_MESSAGE, apiKey: TEST_API_KEY }), + ).rejects.toThrow('no URL'); + }); + + it('throws when text completion returns no content', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('text')); + mockedAxios.post.mockResolvedValueOnce({ data: { choices: [] } }); + + await expect( + processPollinationsRequest({ userMessage: TEST_MESSAGE, apiKey: TEST_API_KEY }), + ).rejects.toThrow('no content'); + }); + + it('returns "coming soon" placeholder for video intent', async () => { + mockClientPost.mockResolvedValueOnce(makeChatResponse('video')); + + const result = await processPollinationsRequest({ + userMessage: TEST_VIDEO_MESSAGE, + apiKey: TEST_API_KEY, + }); + + expect(result.type).toBe('video'); + if (result.type === 'video') { + expect(result.message).toMatch(/coming soon/i); + } + }); + + it('passes the full conversation history for multi-turn text requests', async () => { + const history = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + { role: 'user', content: TEST_MESSAGE }, + ]; + + mockClientPost.mockResolvedValueOnce(makeChatResponse('text')); + mockedAxios.post.mockResolvedValueOnce({ + data: { choices: [{ message: { content: 'reply' } }] }, + }); + + await processPollinationsRequest({ + userMessage: TEST_MESSAGE, + apiKey: TEST_API_KEY, + messages: history, + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/v1/chat/completions'), + expect.objectContaining({ messages: history }), + expect.any(Object), + ); + }); +}); diff --git a/packages/api/src/pollinations/classify.ts b/packages/api/src/pollinations/classify.ts new file mode 100644 index 00000000000..f7980dc40d2 --- /dev/null +++ b/packages/api/src/pollinations/classify.ts @@ -0,0 +1,81 @@ +import axios from 'axios'; + +const DEFAULT_BASE_URL = 'https://gen.pollinations.ai'; + +/** Classifier model โ€” Amazon Nova Micro via Pollinations */ +const CLASSIFIER_MODEL = 'nova-fast'; + +/** System prompt for intent classification */ +const CLASSIFY_SYSTEM = + 'You are an intent classifier. Given a user message, respond with exactly one lowercase word:\n' + + '- "image" if the user wants to generate an image, picture, photo, or illustration\n' + + '- "video" if the user wants to generate a video, animation, or clip\n' + + '- "text" for all other requests (questions, coding, analysis, conversation, etc.)'; + +/** System prompt for image prompt enhancement */ +const ENHANCE_SYSTEM = + 'You are an image prompt enhancer. Given a user image request, rewrite it as a detailed, ' + + 'high-quality image generation prompt. Be concise but descriptive. Return only the enhanced ' + + 'prompt with no explanation.'; + +export type IntentType = 'text' | 'image' | 'video'; + +interface ChatCompletionResponse { + choices: Array<{ + message: { + content: string; + }; + }>; +} + +function buildClient(baseURL: string, apiKey: string) { + return axios.create({ + baseURL, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); +} + +/** + * Classifies a user message's intent as 'text', 'image', or 'video' + * using Pollinations `nova-fast` model. + */ +export async function classifyIntent(userMessage: string, apiKey: string): Promise { + const baseURL = process.env.POLLINATIONS_BASE_URL ?? DEFAULT_BASE_URL; + const client = buildClient(baseURL, apiKey); + + const response = await client.post('/v1/chat/completions', { + model: CLASSIFIER_MODEL, + messages: [ + { role: 'system', content: CLASSIFY_SYSTEM }, + { role: 'user', content: userMessage }, + ], + temperature: 0, + }); + + const raw = response.data.choices[0]?.message?.content?.trim().toLowerCase() ?? 'text'; + if (raw === 'image' || raw === 'video') return raw; + return 'text'; +} + +/** + * Enhances a user's image description into a detailed image generation prompt + * using Pollinations `nova-fast` model. + */ +export async function enhanceImagePrompt(userMessage: string, apiKey: string): Promise { + const baseURL = process.env.POLLINATIONS_BASE_URL ?? DEFAULT_BASE_URL; + const client = buildClient(baseURL, apiKey); + + const response = await client.post('/v1/chat/completions', { + model: CLASSIFIER_MODEL, + messages: [ + { role: 'system', content: ENHANCE_SYSTEM }, + { role: 'user', content: userMessage }, + ], + temperature: 0.7, + }); + + return response.data.choices[0]?.message?.content?.trim() ?? userMessage; +} diff --git a/packages/api/src/pollinations/index.ts b/packages/api/src/pollinations/index.ts new file mode 100644 index 00000000000..efd878e3361 --- /dev/null +++ b/packages/api/src/pollinations/index.ts @@ -0,0 +1,11 @@ +export { classifyIntent, enhanceImagePrompt } from './classify'; +export { processPollinationsRequest, DEFAULT_TEXT_MODEL, IMAGE_MODEL } from './service'; +export type { IntentType } from './classify'; +export type { + PollinationsRequest, + PollinationsResult, + PollinationsTextResult, + PollinationsImageResult, + PollinationsVideoResult, + PollinationsMessage, +} from './service'; diff --git a/packages/api/src/pollinations/service.ts b/packages/api/src/pollinations/service.ts new file mode 100644 index 00000000000..3e4f4c326ec --- /dev/null +++ b/packages/api/src/pollinations/service.ts @@ -0,0 +1,128 @@ +import axios from 'axios'; +import { classifyIntent, enhanceImagePrompt } from './classify'; +import type { IntentType } from './classify'; + +const DEFAULT_BASE_URL = 'https://gen.pollinations.ai'; + +/** Default text model โ€” also used for intent classification */ +export const DEFAULT_TEXT_MODEL = 'nova-fast'; +/** Image generation model */ +export const IMAGE_MODEL = 'zimage'; + +export interface PollinationsTextResult { + type: 'text'; + content: string; + model: string; +} + +export interface PollinationsImageResult { + type: 'image'; + url: string; + enhancedPrompt: string; +} + +export interface PollinationsVideoResult { + type: 'video'; + message: string; +} + +export type PollinationsResult = + | PollinationsTextResult + | PollinationsImageResult + | PollinationsVideoResult; + +export interface PollinationsMessage { + role: string; + content: string; +} + +export interface PollinationsRequest { + /** The user's most recent message */ + userMessage: string; + /** Pollinations API key (`sk_` prefix for server-side use) */ + apiKey: string; + /** Override the text model (defaults to `nova-fast`) */ + model?: string; + /** Full conversation history for multi-turn text requests */ + messages?: PollinationsMessage[]; +} + +interface ImageGenerationResponse { + data: Array<{ url?: string; b64_json?: string }>; +} + +/** + * Processes a Pollinations request by: + * 1. Calling `nova-fast` to classify the user's intent (text / image / video). + * 2. Routing to the appropriate generation flow: + * - **image** โ†’ enhance prompt with `nova-fast`, generate with `zimage` + * - **text** โ†’ chat completion with `nova-fast` (or caller-supplied model) + * - **video** โ†’ placeholder "coming soon" response + */ +export async function processPollinationsRequest({ + userMessage, + apiKey, + model, + messages = [], +}: PollinationsRequest): Promise { + const baseURL = process.env.POLLINATIONS_BASE_URL ?? DEFAULT_BASE_URL; + const intent: IntentType = await classifyIntent(userMessage, apiKey); + + if (intent === 'video') { + return { + type: 'video', + message: 'Video generation is coming soon! Stay tuned.', + }; + } + + if (intent === 'image') { + const enhancedPrompt = await enhanceImagePrompt(userMessage, apiKey); + + const response = await axios.post( + `${baseURL}/v1/images/generations`, + { + model: IMAGE_MODEL, + prompt: enhancedPrompt, + size: '1024x1024', + response_format: 'url', + n: 1, + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const url = response.data.data[0]?.url; + if (!url) { + throw new Error('Pollinations image generation returned no URL.'); + } + + return { type: 'image', url, enhancedPrompt }; + } + + // text + const textModel = model ?? DEFAULT_TEXT_MODEL; + const allMessages: PollinationsMessage[] = + messages.length > 0 ? messages : [{ role: 'user', content: userMessage }]; + + const response = await axios.post<{ choices: Array<{ message: { content: string } }> }>( + `${baseURL}/v1/chat/completions`, + { model: textModel, messages: allMessages }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ); + + const content = response.data.choices[0]?.message?.content; + if (content == null) { + throw new Error('Pollinations text completion returned no content.'); + } + + return { type: 'text', content, model: textModel }; +} From 0710eeadee2b023598d1f04c603a6b1ff62ddc57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:36:59 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20Add=20GUEST=5FMODE=20=E2=80=94=20by?= =?UTF-8?q?pass=20login,=20auto-authenticate=20as=20persistent=20guest=20u?= =?UTF-8?q?ser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GUEST_MODE=true: - POST /api/auth/refresh creates/reuses a shared guest user and returns a real JWT even without a refresh token cookie - Frontend suppresses all login redirects (AuthContext, useAuthRedirect, Root, ChatRoute, Startup all guard with guestMode flag) - Startup.tsx redirects /login โ†’ /c/new immediately when guestMode - Guest account uses a random non-bcrypt password so it cannot be used for local password auth - TStartupConfig extended with guestMode?: boolean - .env.example documents GUEST_MODE=false Agent-Logs-Url: https://github.com/verdjs/LibreChat/sessions/d5aba8f2-9ba1-41a7-b47d-7c07e46a2acc Co-authored-by: verdjs <225778361+verdjs@users.noreply.github.com> --- .env.example | 6 ++++ api/server/controllers/AuthController.js | 45 ++++++++++++++++++++++++ api/server/routes/config.js | 2 ++ client/src/hooks/AuthContext.tsx | 10 ++++-- client/src/routes/ChatRoute.tsx | 2 +- client/src/routes/Layouts/Startup.tsx | 6 +++- client/src/routes/Root.tsx | 2 +- client/src/routes/useAuthRedirect.ts | 8 ++++- packages/data-provider/src/config.ts | 1 + 9 files changed, 75 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index cfcca874f3c..74487079725 100644 --- a/.env.example +++ b/.env.example @@ -463,6 +463,12 @@ ILLEGAL_MODEL_REQ_SCORE=5 # Registration and Login # #========================# +# Set GUEST_MODE=true to bypass all login requirements. +# Visitors are automatically authenticated as a shared guest user โ€” no account needed. +# The guest user is created on first launch and reused on every subsequent request. +# Everything else (chat, conversations, settings) works as normal. +GUEST_MODE=false + ALLOW_EMAIL_LOGIN=true ALLOW_REGISTRATION=true ALLOW_SOCIAL_LOGIN=false diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index eb44feffa4c..b2455ea0ec3 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -1,8 +1,10 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); const openIdClient = require('openid-client'); +const { randomBytes } = require('node:crypto'); const { logger } = require('@librechat/data-schemas'); const { isEnabled, findOpenIDUser } = require('@librechat/api'); +const { SystemRoles } = require('librechat-data-provider'); const { requestPasswordReset, setOpenIDAuthTokens, @@ -13,6 +15,7 @@ const { const { deleteAllUserSessions, getUserById, + createUser, findSession, updateUser, findUser, @@ -20,6 +23,39 @@ const { const { getGraphApiToken } = require('~/server/services/GraphTokenService'); const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); +const GUEST_EMAIL = process.env.GUEST_USER_EMAIL || 'guest@librechat.local'; + +/** + * Finds or creates the shared guest user used in GUEST_MODE. + * The guest user is a regular local user with a stable email address and no password. + * It is created once and reused for every unauthenticated request. + * @returns {Promise} User document without sensitive fields + */ +async function getOrCreateGuestUser() { + let user = await findUser({ email: GUEST_EMAIL }, '-password -__v -totpSecret -backupCodes'); + if (!user) { + const created = await createUser( + { + provider: 'local', + email: GUEST_EMAIL, + username: 'guest', + name: 'Guest', + avatar: null, + role: SystemRoles.USER, + emailVerified: true, + // Use a random, non-bcrypt-hashed value so the guest account + // can never be used to log in via password-based local auth. + password: randomBytes(32).toString('hex'), + }, + undefined, + true, + false, + ); + user = await getUserById(created._id.toString(), '-password -__v -totpSecret -backupCodes'); + } + return user; +} + const registrationController = async (req, res) => { try { const response = await registerUser(req.body); @@ -130,6 +166,15 @@ const refreshController = async (req, res) => { /** For non-OpenID users, read refresh token from cookies */ const refreshToken = parsedCookies.refreshToken; if (!refreshToken) { + if (isEnabled(process.env.GUEST_MODE)) { + try { + const guestUser = await getOrCreateGuestUser(); + const token = await setAuthTokens(guestUser._id.toString(), res); + return res.status(200).send({ token, user: guestUser }); + } catch (err) { + logger.error('[refreshController] Guest mode auto-auth failed:', err); + } + } return res.status(200).send('Refresh token not provided'); } diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a57e4bd958b..bf8e770ecef 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -9,6 +9,7 @@ const router = express.Router(); const emailLoginEnabled = process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN); const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET); +const guestMode = isEnabled(process.env.GUEST_MODE); const sharedLinksEnabled = process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS); @@ -77,6 +78,7 @@ function buildSharedPayload() { publicSharedLinksEnabled, analyticsGtmId: process.env.ANALYTICS_GTM_ID, openidReuseTokens, + guestMode, }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 2fb7dd77601..02ca92c3531 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -24,6 +24,7 @@ import { useLoginUserMutation, useLogoutUserMutation, useRefreshTokenMutation, + useGetStartupConfig, } from '~/data-provider'; import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common'; import { SESSION_KEY, isSafeRedirect, getPostLoginRedirect } from '~/utils'; @@ -47,6 +48,9 @@ const AuthContextProvider = ({ const [isAuthenticated, setIsAuthenticated] = useState(false); const setQueriesEnabled = useSetRecoilState(store.queriesEnabled); + const { data: startupConfig } = useGetStartupConfig(); + const guestMode = startupConfig?.guestMode === true; + const { data: userRole = null } = useGetRole(SystemRoles.USER, { enabled: !!(isAuthenticated && (user?.role ?? '')), }); @@ -191,7 +195,7 @@ const AuthContextProvider = ({ return; } console.log('Token is not present. User is not authenticated.'); - if (authConfig?.test === true) { + if (authConfig?.test === true || guestMode) { return; } navigate(buildLoginRedirectUrl()); @@ -201,7 +205,7 @@ const AuthContextProvider = ({ return; } console.log('refreshToken mutation error:', error); - if (authConfig?.test === true) { + if (authConfig?.test === true || guestMode) { return; } navigate(buildLoginRedirectUrl()); @@ -216,7 +220,7 @@ const AuthContextProvider = ({ } if (userQuery.data) { setUser(userQuery.data); - } else if (userQuery.isError) { + } else if (userQuery.isError && !guestMode) { doSetError((userQuery.error as Error).message); navigate(buildLoginRedirectUrl(), { replace: true }); } diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index a17d349037d..07bf28601cc 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -200,7 +200,7 @@ export default function ChatRoute() { ); } - if (!isAuthenticated) { + if (!isAuthenticated && !startupConfig?.guestMode) { return null; } diff --git a/client/src/routes/Layouts/Startup.tsx b/client/src/routes/Layouts/Startup.tsx index bb0e5ef2542..d85499baba1 100644 --- a/client/src/routes/Layouts/Startup.tsx +++ b/client/src/routes/Layouts/Startup.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'; import type { TStartupConfig } from 'librechat-data-provider'; import { TranslationKeys, useLocalize } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; @@ -52,6 +52,10 @@ export default function StartupLayout({ isAuthenticated }: { isAuthenticated?: b setHeaderText(null); }, [location.pathname]); + if (data?.guestMode) { + return ; + } + const contextValue = { error, setError, diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index e7e08f68799..5fb94d75c7e 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -59,7 +59,7 @@ export default function Root() { logout('/login?redirect=false'); }; - if (!isAuthenticated) { + if (!isAuthenticated && !config?.guestMode) { return null; } diff --git a/client/src/routes/useAuthRedirect.ts b/client/src/routes/useAuthRedirect.ts index cc277cd74e6..a254e5a5d9d 100644 --- a/client/src/routes/useAuthRedirect.ts +++ b/client/src/routes/useAuthRedirect.ts @@ -2,13 +2,19 @@ import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { buildLoginRedirectUrl } from 'librechat-data-provider'; import { useAuthContext } from '~/hooks'; +import { useGetStartupConfig } from '~/data-provider'; export default function useAuthRedirect() { const { user, roles, isAuthenticated } = useAuthContext(); + const { data: startupConfig } = useGetStartupConfig(); const navigate = useNavigate(); const location = useLocation(); useEffect(() => { + if (startupConfig?.guestMode) { + return; + } + const timeout = setTimeout(() => { if (isAuthenticated) { return; @@ -22,7 +28,7 @@ export default function useAuthRedirect() { return () => { clearTimeout(timeout); }; - }, [isAuthenticated, navigate, location]); + }, [isAuthenticated, navigate, location, startupConfig?.guestMode]); return { user, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ae3f5b95603..cc780c47883 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -825,6 +825,7 @@ export type TStartupConfig = { sharePointPickerSharePointScope?: string; openidReuseTokens?: boolean; minPasswordLength?: number; + guestMode?: boolean; webSearch?: { searchProvider?: SearchProviders; scraperProvider?: ScraperProviders;