From af75920edc28ae04962e4823b5de657955b9ae93 Mon Sep 17 00:00:00 2001 From: "Wolf G. Beckmann" Date: Sun, 10 May 2026 11:11:59 +0200 Subject: [PATCH] Add initial implementation of noThinkProxy with Docker support and README documentation --- .gitignore | 2 + Dockerfile | 14 +++ README.md | 96 ++++++++++++++++++++ app.js | 213 ++++++++++++++++++++++++++++++++------------- docker-compose.yml | 23 +++++ 5 files changed, 286 insertions(+), 62 deletions(-) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index c2658d7..5f68a45 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules/ +.env +*.v[0-9]* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61b0f80 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY app.js ./ + +ENV PORT=11435 + +EXPOSE 11435 + +CMD ["node", "app.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cad7340 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# noThinkProxy + +Anthropic API → Ollama Proxy. +Ermöglicht Claude-SDK-Clients (z. B. **Claude Code**) gegen einen lokalen Ollama-Server zu arbeiten, ohne Think-Modus und mit frei wählbarem Modell. + +## Was der Proxy tut + +- Empfängt Requests im **Anthropic-Format** (`POST /v1/messages`) +- Konvertiert sie ins **Ollama-Format** (`POST /api/chat`) +- Gibt die Antwort als **Anthropic SSE-Stream** zurück +- Deaktiviert den Think-Modus (`think: false`) +- Ersetzt alle `claude-*` Modellnamen durch das konfigurierte Modell +- Konvertiert Tool-Calls und Tool-Results korrekt ins Ollama-Format +- Dedupliziert doppelte Tool-Calls + +## Schnellstart (Docker) + +### 1. Image bauen und in Registry pushen + +```bash +docker build -t registry.aquantico.lan/nothinkproxy:latest . +docker push registry.aquantico.lan/nothinkproxy:latest +``` + +### 2. Auf dem Server deployen + +```bash +# docker-compose.yml auf den Server kopieren +scp docker-compose.yml server:/opt/nothinkproxy/ + +# Auf dem Server starten +ssh server +cd /opt/nothinkproxy +docker compose up -d +``` + +Der Proxy ist dann erreichbar unter `https://claude-proxy.aquantico.lan`. + +## Konfiguration (Umgebungsvariablen) + +| Variable | Standard | Beschreibung | +|---------------|-----------------------------------------------|-------------------------------------| +| `PORT` | `11435` | Port auf dem der Proxy lauscht | +| `OLLAMA_URL` | `https://ollama.aquantico.de/api/chat` | Vollständige Ollama Chat-API URL | +| `OLLAMA_MODEL`| `qwen3.6:35b-a3b-q4_K_M` | Modell das Ollama verwenden soll | +| `OLLAMA_AUTH` | *(Bearer Token)* | Auth-Token für den Ollama-Server | + +Anpassen in `docker-compose.yml` unter `environment:`. + +## Claude Code installieren + +Auf dem Zielrechner (einmaliger Installer): + +```bash +curl -fsSL https://claude-proxy.aquantico.lan/install.sh | bash +``` + +Der Installer prüft Node.js, installiert es bei Bedarf und installiert Claude Code via npm. + +## Claude Code mit dem Proxy starten + +```bash +ANTHROPIC_BASE_URL=https://claude-proxy.aquantico.lan claude +``` + +Als dauerhafter Alias in `~/.bashrc` oder `~/.zshrc`: + +```bash +alias claude-local='ANTHROPIC_BASE_URL=https://claude-proxy.aquantico.lan claude' +``` + +## Lokaler Betrieb (ohne Docker) + +```bash +npm install +node app.js +``` + +Mit anderem Modell / Server: + +```bash +OLLAMA_URL=http://localhost:11434/api/chat \ +OLLAMA_MODEL=llama3.2 \ +OLLAMA_AUTH=mytoken \ +node app.js +``` + +Dann Claude Code starten: + +```bash +ANTHROPIC_BASE_URL=http://localhost:11435 claude +``` + +## Info-Seite + +Beim Aufruf von `https://claude-proxy.aquantico.lan/` (ohne Pfad) erscheint eine HTML-Seite mit der aktuellen Konfiguration und Installations-Anweisungen. diff --git a/app.js b/app.js index 185c213..a59f16e 100644 --- a/app.js +++ b/app.js @@ -1,20 +1,138 @@ -// proxy.js - KOMPLETT, mit Tool-Call-Deduplizierung - const express = require('express'); const app = express(); +const OLLAMA_URL = process.env.OLLAMA_URL || 'https://ollama.aquantico.de/api/chat'; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.6:35b-a3b-q4_K_M'; +const OLLAMA_AUTH = process.env.OLLAMA_AUTH || '324GF44-50AA-4B57-9386-K435DLJ764DFR'; +const PORT = parseInt(process.env.PORT || '11435', 10); + const colors = { - reset: '\x1b[0m', - cyan: '\x1b[36m', - green: '\x1b[32m', + reset: '\x1b[0m', + cyan: '\x1b[36m', + green: '\x1b[32m', magenta: '\x1b[35m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - red: '\x1b[31m' + yellow: '\x1b[33m', + blue: '\x1b[34m', + red: '\x1b[31m' }; +app.set('trust proxy', 1); app.use(express.json({ limit: '50mb' })); +// ── Info-Seite ──────────────────────────────────────────────────────────────── + +app.get('/', (req, res) => { + const host = `${req.protocol}://${req.get('host')}`; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(` + + + + + noThinkProxy + + + +

noThinkProxy v1.0

+

Anthropic-API → Ollama-Proxy · Think-Modus deaktiviert · Modell-Substitution aktiv

+ +

Aktuelle Konfiguration

+ + + + + + + +
ParameterWert
Ollama URL${OLLAMA_URL.replace(/\/api\/chat$/, '')}
Modell${OLLAMA_MODEL}
Kontext262144 Token (256k)
Thinkfalse
Proxy-URL${host}
+ +

localclaude installieren

+

Installiert das Script localclaude nach /usr/local/bin (oder ~/.local/bin):

+
curl -fsSL ${host}/install.sh | bash
+ +

Starten

+
localclaude
+

localclaude setzt automatisch ANTHROPIC_BASE_URL=${host} und ruft claude auf.

+ +

API-Endpunkt

+
POST ${host}/v1/messages
+

Kompatibel mit dem Anthropic SDK. Alle claude-* Modellnamen werden automatisch auf ${OLLAMA_MODEL} umgeleitet.

+ +`); +}); + +// ── Install-Script ──────────────────────────────────────────────────────────── + +app.get('/install.sh', (req, res) => { + const host = `${req.protocol}://${req.get('host')}`; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(`#!/usr/bin/env bash +set -euo pipefail + +PROXY_URL="${host}" +INSTALL_DIR="/usr/local/bin" +NEEDS_PATH_UPDATE=false + +echo "" +echo "=== noThinkProxy · localclaude Installer ===" +echo "" + +# Zielverzeichnis bestimmen (ohne sudo → ~/.local/bin) +if [ ! -w "\$INSTALL_DIR" ]; then + INSTALL_DIR="\$HOME/.local/bin" + mkdir -p "\$INSTALL_DIR" +fi + +# localclaude-Script schreiben +cat > "\$INSTALL_DIR/localclaude" <<'SCRIPT' +#!/usr/bin/env bash +export ANTHROPIC_BASE_URL="${host}" +exec claude "\$@" +SCRIPT + +chmod +x "\$INSTALL_DIR/localclaude" + +# PATH prüfen und ggf. in Shell-Config eintragen +if ! echo "\$PATH" | grep -q "\$INSTALL_DIR"; then + NEEDS_PATH_UPDATE=true + echo "» Trage \$INSTALL_DIR in ~/.bashrc und ~/.zshrc ein..." + echo "export PATH=\\"\$INSTALL_DIR:\$PATH\\"" >> "\$HOME/.bashrc" + echo "export PATH=\\"\$INSTALL_DIR:\$PATH\\"" >> "\$HOME/.zshrc" 2>/dev/null || true +fi + +echo "✓ localclaude installiert in \$INSTALL_DIR" +echo "" +if [ "\$NEEDS_PATH_UPDATE" = "true" ]; then + echo "────────────────────────────────────────────" + echo "Führe diesen Befehl jetzt aus damit localclaude sofort verfügbar ist:" + echo "" + echo " export PATH=\\"\$INSTALL_DIR:\$PATH\\"" + echo "" + echo "In neuen Shell-Sessions ist es automatisch verfügbar." + echo "────────────────────────────────────────────" +else + echo "Starte mit: localclaude" +fi +echo "" +`); +}); + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── + function sanitizeToolSchema(schema) { if (!schema || typeof schema !== 'object') { return { type: 'object', properties: {} }; @@ -70,7 +188,6 @@ function stringifyToolResultContent(content) { return JSON.stringify(content); } -// BUG 3 FIX: tool_use → Ollama tool_calls, tool_result → role:tool function convertAnthropicToOllama(anthropicBody) { const ollamaMessages = []; @@ -116,7 +233,6 @@ function convertAnthropicToOllama(anthropicBody) { ollamaMessages.push(assistantMsg); } else { - // user messages: text bleibt als user, tool_result wird zu role:tool const pendingText = []; for (const item of msg.content) { @@ -178,9 +294,7 @@ function parseToolArguments(args) { } } - if (typeof args === 'object') { - return args; - } + if (typeof args === 'object') return args; return {}; } @@ -193,6 +307,8 @@ function makeToolDedupeKey(tc) { return `${name}:${argsString}`; } +// ── Response-Handler ────────────────────────────────────────────────────────── + async function handleResponse(response, anthropicBody, res, requestNum) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -200,7 +316,6 @@ async function handleResponse(response, anthropicBody, res, requestNum) { const messageId = 'msg_' + requestNum; - // BUG 2 FIX: usage.input_tokens und stop_reason hinzugefügt res.write(`event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: { @@ -226,7 +341,6 @@ async function handleResponse(response, anthropicBody, res, requestNum) { let messageFinished = false; let buffer = ''; - // BUG 1 FIX: Chunk-Verarbeitung als Funktion, damit final buffer ebenfalls verarbeitet wird function processChunk(data) { if (messageFinished) return; @@ -253,21 +367,13 @@ async function handleResponse(response, anthropicBody, res, requestNum) { res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: currentBlockIndex, - content_block: { - type: 'tool_use', - id: toolUseId, - name: toolName, - input: {} - } + content_block: { type: 'tool_use', id: toolUseId, name: toolName, input: {} } })}\n\n`); res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: currentBlockIndex, - delta: { - type: 'input_json_delta', - partial_json: JSON.stringify(toolInput) - } + delta: { type: 'input_json_delta', partial_json: JSON.stringify(toolInput) } })}\n\n`); res.write(`event: content_block_stop\ndata: ${JSON.stringify({ @@ -286,10 +392,7 @@ async function handleResponse(response, anthropicBody, res, requestNum) { res.write(`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: currentBlockIndex, - content_block: { - type: 'text', - text: '' - } + content_block: { type: 'text', text: '' } })}\n\n`); contentBlocks[currentBlockIndex] = ''; @@ -300,10 +403,7 @@ async function handleResponse(response, anthropicBody, res, requestNum) { res.write(`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: currentBlockIndex, - delta: { - type: 'text_delta', - text - } + delta: { type: 'text_delta', text } })}\n\n`); contentBlocks[currentBlockIndex] += text; @@ -321,17 +421,11 @@ async function handleResponse(response, anthropicBody, res, requestNum) { res.write(`event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', - delta: { - stop_reason: emittedToolUse ? 'tool_use' : 'end_turn' - }, - usage: { - output_tokens: data.eval_count || 0 - } + delta: { stop_reason: emittedToolUse ? 'tool_use' : 'end_turn' }, + usage: { output_tokens: data.eval_count || 0 } })}\n\n`); - res.write(`event: message_stop\ndata: ${JSON.stringify({ - type: 'message_stop' - })}\n\n`); + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`); console.log(`${colors.green}✓${colors.reset}\n`); } @@ -359,7 +453,6 @@ async function handleResponse(response, anthropicBody, res, requestNum) { } } - // BUG 1 FIX: letzten gepufferten Chunk verarbeiten (kein \n am Ende) if (buffer.trim()) { try { processChunk(JSON.parse(buffer.trim())); @@ -379,22 +472,18 @@ async function handleResponse(response, anthropicBody, res, requestNum) { res.write(`event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', - delta: { - stop_reason: emittedToolUse ? 'tool_use' : 'end_turn' - }, - usage: { - output_tokens: 0 - } + delta: { stop_reason: emittedToolUse ? 'tool_use' : 'end_turn' }, + usage: { output_tokens: 0 } })}\n\n`); - res.write(`event: message_stop\ndata: ${JSON.stringify({ - type: 'message_stop' - })}\n\n`); + res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`); } res.end(); } +// ── Proxy-Endpunkt ──────────────────────────────────────────────────────────── + app.post('/v1/messages', async (req, res) => { const requestNum = Date.now(); @@ -404,20 +493,20 @@ app.post('/v1/messages', async (req, res) => { const anthropicBody = req.body; if (anthropicBody.model?.startsWith('claude-')) { - anthropicBody.model = 'qwen3.6:35b-a3b-q4_K_M'; + anthropicBody.model = OLLAMA_MODEL; } const ollamaBody = convertAnthropicToOllama(anthropicBody); console.log( - `${colors.magenta}[msgs=${ollamaBody.messages.length}, tools=${ollamaBody.tools?.length || 0}, ctx=256k, think=false]${colors.reset}` + `${colors.magenta}[msgs=${ollamaBody.messages.length}, tools=${ollamaBody.tools?.length || 0}, ctx=256k, think=false, model=${OLLAMA_MODEL}]${colors.reset}` ); - const response = await fetch('https://ollama.aquantico.de/api/chat', { + const response = await fetch(OLLAMA_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': 'Bearer 324GF44-50AA-4B57-9386-K435DLJ764DFR' + 'Authorization': `Bearer ${OLLAMA_AUTH}` }, body: JSON.stringify(ollamaBody) }); @@ -435,10 +524,7 @@ app.post('/v1/messages', async (req, res) => { if (!res.headersSent) { res.status(500).json({ type: 'error', - error: { - type: 'api_error', - message: error.message - } + error: { type: 'api_error', message: error.message } }); } else { res.end(); @@ -446,6 +532,9 @@ app.post('/v1/messages', async (req, res) => { } }); -app.listen(11435, () => { - console.log(`${colors.magenta}Proxy: localhost:11435 (256k ctx, think=false)${colors.reset}\n`); +app.listen(PORT, () => { + console.log(`${colors.magenta}noThinkProxy: localhost:${PORT}${colors.reset}`); + console.log(`${colors.cyan} Ollama : ${OLLAMA_URL}${colors.reset}`); + console.log(`${colors.cyan} Modell : ${OLLAMA_MODEL}${colors.reset}`); + console.log(`${colors.cyan} Ctx : 256k Think: false${colors.reset}\n`); }); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9c2d3f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + nothinkproxy: + image: registry.aquantico.lan/nothinkproxy:latest + container_name: nothinkproxy + restart: always + networks: + - traefik + environment: + - PORT=11435 + - OLLAMA_URL=https://ollama.aquantico.de/api/chat + - OLLAMA_MODEL=qwen3.6:35b-a3b-q4_K_M + - OLLAMA_AUTH=324GF44-50AA-4B57-9386-K435DLJ764DFR + labels: + - "traefik.enable=true" + - "traefik.http.routers.nothinkproxy.rule=Host(`claude-proxy.aquantico.lan`)" + - "traefik.http.routers.nothinkproxy.entrypoints=websecure" + - "traefik.http.routers.nothinkproxy.tls=true" + - "traefik.http.services.nothinkproxy.loadbalancer.server.port=11435" + - "traefik.docker.network=traefik" + +networks: + traefik: + external: true