refactor: split openai/ into ollama/, openwebui/, searxng/

Move services out of the monolithic openai/docker-compose.yml:
- ollama/ — ollama GPU + CPU inference servers
- openwebui/ — open-webui chat UI (uses env var for ANTHROPIC_API_KEY)
- searxng/ — SearXNG container + searxng-mcp MCP server (port 3102)

openai/ now contains only: litellm, langfuse, qdrant, faster-whisper,
silero-tts, pipecat.

searxng-mcp exposes a single searxng_search tool via MCP HTTP on :3102.
Registered in ~/.claude.json as the "searxng" MCP server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alvis
2026-06-11 05:05:45 +00:00
parent 85033136d8
commit 7ef3ffa00e
7 changed files with 194 additions and 71 deletions

27
ollama/docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
ollama:
image: ollama/ollama
container_name: ollama
ports:
- "11436:11434"
volumes:
- /mnt/ssd/ai/ollama:/root/.ollama
restart: always
environment:
# Allow qwen3:8b + qwen2.5:1.5b to coexist in VRAM (~6.7-7.7 GB on 8 GB GPU)
- OLLAMA_MAX_LOADED_MODELS=2
# One GPU inference at a time — prevents compute contention between models
- OLLAMA_NUM_PARALLEL=1
# Force all layers to GPU — fail instead of falling back to CPU
- OLLAMA_NUM_GPU=999
runtime: nvidia
mem_limit: 4g
ollama-cpu:
image: ollama/ollama
container_name: ollama-cpu
ports:
- "11435:11434"
volumes:
- /mnt/ssd/ai/ollama-cpu:/root/.ollama
restart: always

View File

@@ -1,65 +1,4 @@
services: services:
ollama:
image: ollama/ollama
container_name: ollama
ports:
- "11436:11434"
volumes:
- /mnt/ssd/ai/ollama:/root/.ollama
- /mnt/ssd/ai/open-webui:/app/backend/data
restart: always
environment:
# Allow qwen3:8b + qwen2.5:1.5b to coexist in VRAM (~6.7-7.7 GB on 8 GB GPU)
- OLLAMA_MAX_LOADED_MODELS=2
# One GPU inference at a time — prevents compute contention between models
- OLLAMA_NUM_PARALLEL=1
# Force all layers to GPU — fail instead of falling back to CPU
- OLLAMA_NUM_GPU=999
runtime: nvidia
mem_limit: 4g
ollama-cpu:
image: ollama/ollama
container_name: ollama-cpu
ports:
- "11435:11434"
volumes:
- /mnt/ssd/ai/ollama-cpu:/root/.ollama
restart: always
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "3125:8080"
volumes:
- /mnt/ssd/ai/open-webui:/app/backend/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: always
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
- ANTHROPIC_API_KEY=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
- OPENAI_API_BASE_URL=http://host.docker.internal:4000/v1
- OPENAI_API_KEY=dummy
# STT — Faster-Whisper large-v3-turbo
- AUDIO_STT_ENGINE=openai
- AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:8880/v1
- AUDIO_STT_OPENAI_API_KEY=dummy
- AUDIO_STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
# TTS — Silero v4
- AUDIO_TTS_ENGINE=openai
- AUDIO_TTS_OPENAI_API_BASE_URL=http://host.docker.internal:8881/v1
- AUDIO_TTS_OPENAI_API_KEY=dummy
- AUDIO_TTS_MODEL=silero
- AUDIO_TTS_VOICE=onyx
litellm-db: litellm-db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: litellm-db container_name: litellm-db
@@ -119,16 +58,6 @@ services:
- langfuse-db - langfuse-db
restart: always restart: always
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
volumes:
- /mnt/ssd/ai/searxng/config/:/etc/searxng/
- /mnt/ssd/ai/searxng/data/:/var/cache/searxng/
restart: always
ports:
- "11437:8080"
qdrant: qdrant:
image: qdrant/qdrant image: qdrant/qdrant
container_name: qdrant container_name: qdrant

View File

@@ -0,0 +1,33 @@
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "3125:8080"
volumes:
- /mnt/ssd/ai/open-webui:/app/backend/data
extra_hosts:
- "host.docker.internal:host-gateway"
restart: always
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_BASE_URL=http://host.docker.internal:4000/v1
- OPENAI_API_KEY=dummy
# STT — Faster-Whisper large-v3-turbo
- AUDIO_STT_ENGINE=openai
- AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:8880/v1
- AUDIO_STT_OPENAI_API_KEY=dummy
- AUDIO_STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
# TTS — Silero v4
- AUDIO_TTS_ENGINE=openai
- AUDIO_TTS_OPENAI_API_BASE_URL=http://host.docker.internal:8881/v1
- AUDIO_TTS_OPENAI_API_KEY=dummy
- AUDIO_TTS_MODEL=silero
- AUDIO_TTS_VOICE=onyx

View File

@@ -0,0 +1,19 @@
services:
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
volumes:
- /mnt/ssd/ai/searxng/config/:/etc/searxng/
- /mnt/ssd/ai/searxng/data/:/var/cache/searxng/
restart: always
ports:
- "11437:8080"
searxng-mcp:
build: ./mcp
container_name: searxng-mcp
network_mode: host
restart: unless-stopped
environment:
- PORT=3102
- SEARXNG_URL=http://localhost:11437

6
searxng/mcp/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
CMD ["node", "server.js"]

11
searxng/mcp/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "searxng-mcp",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"express": "^4.19.0",
"zod": "^3.23.0"
}
}

98
searxng/mcp/server.js Normal file
View File

@@ -0,0 +1,98 @@
import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { z } from 'zod';
const PORT = parseInt(process.env.PORT || '3102');
const SEARXNG_URL = (process.env.SEARXNG_URL || 'http://localhost:11437').replace(/\/$/, '');
function createServer() {
const server = new McpServer({ name: 'searxng-mcp', version: '1.0.0' });
server.tool(
'searxng_search',
'Search the web using the self-hosted SearXNG meta-search engine. Returns titles, URLs, and content snippets from multiple search engines.',
{
query: z.string().describe('Search query'),
categories: z.enum(['general', 'news', 'images', 'videos', 'science', 'files', 'social_media', 'it'])
.optional()
.describe('Search category, default: general'),
language: z.string().optional().describe('Language code (e.g. "ru", "en", "auto"), default: auto'),
time_range: z.enum(['day', 'week', 'month', 'year']).optional().describe('Limit results to time range'),
limit: z.number().optional().describe('Max results to return, default 10'),
},
async ({ query, categories, language, time_range, limit = 10 }) => {
try {
const params = new URLSearchParams({
q: query,
format: 'json',
categories: categories || 'general',
language: language || 'auto',
});
if (time_range) params.set('time_range', time_range);
const res = await fetch(`${SEARXNG_URL}/search?${params}`);
if (!res.ok) {
return { content: [{ type: 'text', text: `SearXNG returned HTTP ${res.status}` }], isError: true };
}
const data = await res.json();
const results = (data.results || []).slice(0, limit).map(r => ({
title: r.title || null,
url: r.url || null,
content: (r.content || '').slice(0, 500) || null,
engine: r.engine || null,
score: r.score != null ? Math.round(r.score * 100) / 100 : null,
publishedDate: r.publishedDate || null,
}));
const out = {
query: data.query,
totalResults: data.number_of_results,
count: results.length,
results,
};
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
} catch (e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
}
);
return server;
}
const app = express();
app.use(express.json());
const sseTransports = new Map();
app.all('/mcp', async (req, res) => {
try {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => transport.close());
await createServer().connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
if (!res.headersSent) res.status(500).json({ error: e.message });
}
});
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
await createServer().connect(transport);
});
app.post('/messages', async (req, res) => {
const transport = sseTransports.get(req.query.sessionId);
if (!transport) return res.status(400).send('Unknown session');
await transport.handlePostMessage(req, res);
});
app.get('/health', (_, res) => res.json({ status: 'ok', searxng: SEARXNG_URL }));
app.listen(PORT, () => console.log(`searxng-mcp listening on :${PORT}`));