Files
Antrophic-Qwen3.6-Proxy/app.js

541 lines
17 KiB
JavaScript
Raw Normal View History

2026-05-10 10:46:41 +02:00
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);
2026-05-10 10:46:41 +02:00
const colors = {
reset: '\x1b[0m',
cyan: '\x1b[36m',
green: '\x1b[32m',
2026-05-10 10:46:41 +02:00
magenta: '\x1b[35m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m'
2026-05-10 10:46:41 +02:00
};
app.set('trust proxy', 1);
2026-05-10 10:46:41 +02:00
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(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>noThinkProxy</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#0f0f0f;color:#e0e0e0;padding:2rem}
h1{color:#a78bfa;font-size:1.8rem;margin-bottom:.4rem}
h2{color:#7dd3fc;font-size:1.1rem;margin:2rem 0 .6rem}
p{color:#9ca3af;line-height:1.6;margin-bottom:.8rem}
code{background:#1e1e2e;color:#cba6f7;padding:.15rem .4rem;border-radius:.25rem;font-size:.9rem}
pre{background:#1e1e2e;border:1px solid #333;border-radius:.5rem;padding:1rem;overflow-x:auto;margin:.5rem 0 1rem}
pre code{background:none;padding:0;color:#a6e3a1}
.badge{display:inline-block;background:#1e3a5f;color:#7dd3fc;border-radius:.25rem;padding:.1rem .5rem;font-size:.8rem;margin-left:.5rem}
table{width:100%;border-collapse:collapse;margin:.5rem 0 1rem}
td,th{border:1px solid #333;padding:.4rem .8rem;text-align:left}
th{background:#1e1e2e;color:#7dd3fc}
.copy-btn{cursor:pointer;background:#2d2d4e;border:1px solid #555;color:#cba6f7;padding:.2rem .6rem;border-radius:.25rem;font-size:.75rem;margin-left:.5rem}
</style>
</head>
<body>
<h1>noThinkProxy <span class="badge">v1.0</span></h1>
<p>Anthropic-API Ollama-Proxy · Think-Modus deaktiviert · Modell-Substitution aktiv</p>
<h2>Aktuelle Konfiguration</h2>
<table>
<tr><th>Parameter</th><th>Wert</th></tr>
<tr><td>Ollama URL</td><td><code>${OLLAMA_URL.replace(/\/api\/chat$/, '')}</code></td></tr>
<tr><td>Modell</td><td><code>${OLLAMA_MODEL}</code></td></tr>
<tr><td>Kontext</td><td><code>262144 Token (256k)</code></td></tr>
<tr><td>Think</td><td><code>false</code></td></tr>
<tr><td>Proxy-URL</td><td><code>${host}</code></td></tr>
</table>
<h2>localclaude installieren</h2>
<p>Installiert das Script <code>localclaude</code> nach <code>/usr/local/bin</code> (oder <code>~/.local/bin</code>):</p>
<pre><code>curl -fsSL ${host}/install.sh | bash</code></pre>
<h2>Starten</h2>
<pre><code>localclaude</code></pre>
<p><code>localclaude</code> setzt automatisch <code>ANTHROPIC_BASE_URL=${host}</code> und ruft <code>claude</code> auf.</p>
<h2>API-Endpunkt</h2>
<pre><code>POST ${host}/v1/messages</code></pre>
<p>Kompatibel mit dem Anthropic SDK. Alle <code>claude-*</code> Modellnamen werden automatisch auf <code>${OLLAMA_MODEL}</code> umgeleitet.</p>
</body>
</html>`);
});
// ── 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 ───────────────────────────────────────────────────────────
2026-05-10 10:46:41 +02:00
function sanitizeToolSchema(schema) {
if (!schema || typeof schema !== 'object') {
return { type: 'object', properties: {} };
}
const clean = JSON.parse(JSON.stringify(schema));
if (!clean.type) clean.type = 'object';
if (!clean.properties) clean.properties = {};
return clean;
}
function convertAnthropicTools(anthropicTools) {
if (!anthropicTools || anthropicTools.length === 0) return [];
const validTools = [];
for (const tool of anthropicTools) {
try {
const ollamaTool = {
type: 'function',
function: {
name: tool.name,
description: (tool.description || '').substring(0, 500),
parameters: sanitizeToolSchema(tool.input_schema)
}
};
JSON.stringify(ollamaTool);
validTools.push(ollamaTool);
} catch (e) {
console.error(`${colors.red}[Tool Schema Error] ${e.message}${colors.reset}`);
}
}
return validTools;
}
function stringifyToolResultContent(content) {
if (Array.isArray(content)) {
return content
.map(c => {
if (typeof c === 'string') return c;
if (c?.text) return c.text;
return JSON.stringify(c);
})
.join('\n');
}
if (typeof content === 'string') return content;
return JSON.stringify(content);
}
function convertAnthropicToOllama(anthropicBody) {
const ollamaMessages = [];
if (anthropicBody.system) {
ollamaMessages.push({
role: 'system',
content:
typeof anthropicBody.system === 'string'
? anthropicBody.system
: JSON.stringify(anthropicBody.system)
});
}
for (const msg of anthropicBody.messages || []) {
if (typeof msg.content === 'string') {
ollamaMessages.push({ role: msg.role, content: msg.content });
continue;
}
if (!Array.isArray(msg.content)) continue;
if (msg.role === 'assistant') {
const textParts = [];
const toolCalls = [];
for (const item of msg.content) {
if (item.type === 'text') {
textParts.push(item.text || '');
} else if (item.type === 'tool_use') {
toolCalls.push({
function: {
name: item.name,
arguments: item.input || {}
}
});
}
}
const assistantMsg = { role: 'assistant', content: textParts.join('\n\n') };
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
ollamaMessages.push(assistantMsg);
} else {
const pendingText = [];
for (const item of msg.content) {
if (item.type === 'text') {
pendingText.push(item.text || '');
} else if (item.type === 'tool_result') {
if (pendingText.length > 0) {
ollamaMessages.push({ role: 'user', content: pendingText.join('\n\n') });
pendingText.length = 0;
}
const resultText = stringifyToolResultContent(item.content);
console.log(`${colors.blue}📥 Tool Result ${item.tool_use_id}:${colors.reset}`);
console.log(`${colors.blue}${resultText}${colors.reset}`);
console.log('');
ollamaMessages.push({ role: 'tool', content: resultText });
}
}
if (pendingText.length > 0) {
ollamaMessages.push({ role: 'user', content: pendingText.join('\n\n') });
}
}
}
const ollamaBody = {
model: anthropicBody.model,
messages: ollamaMessages,
stream: anthropicBody.stream !== false,
think: false,
options: {
temperature: 0.7,
num_predict: anthropicBody.max_tokens || 4096,
num_ctx: 262144
}
};
if (anthropicBody.tools && anthropicBody.tools.length > 0) {
const validTools = convertAnthropicTools(anthropicBody.tools);
if (validTools.length > 0) {
ollamaBody.tools = validTools;
}
}
return ollamaBody;
}
function parseToolArguments(args) {
if (!args) return {};
if (typeof args === 'string') {
try {
return JSON.parse(args);
} catch (e) {
console.error(`${colors.red}[Tool Args Parse Error] ${e.message}${colors.reset}`);
return {};
}
}
if (typeof args === 'object') return args;
2026-05-10 10:46:41 +02:00
return {};
}
function makeToolDedupeKey(tc) {
const name = tc.function?.name || '';
const args = tc.function?.arguments || {};
const argsString = typeof args === 'string' ? args : JSON.stringify(args);
return `${name}:${argsString}`;
}
// ── Response-Handler ──────────────────────────────────────────────────────────
2026-05-10 10:46:41 +02:00
async function handleResponse(response, anthropicBody, res, requestNum) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const messageId = 'msg_' + requestNum;
res.write(`event: message_start\ndata: ${JSON.stringify({
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
content: [],
model: anthropicBody.model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 }
}
})}\n\n`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let contentBlocks = [];
let currentBlockIndex = 0;
const seenToolCalls = new Set();
let emittedToolUse = false;
let messageFinished = false;
let buffer = '';
function processChunk(data) {
if (messageFinished) return;
if (data.message?.tool_calls && data.message.tool_calls.length > 0) {
for (const tc of data.message.tool_calls) {
const dedupeKey = makeToolDedupeKey(tc);
if (seenToolCalls.has(dedupeKey)) {
console.log(`${colors.yellow}[Duplicate Tool Call skipped] ${dedupeKey}${colors.reset}`);
continue;
}
seenToolCalls.add(dedupeKey);
emittedToolUse = true;
const toolName = tc.function?.name;
const toolInput = parseToolArguments(tc.function?.arguments);
const toolUseId = `toolu_${requestNum}_${currentBlockIndex}`;
console.log(`${colors.yellow}[Raw Tool Call] ${JSON.stringify(tc)}${colors.reset}`);
console.log(`${colors.magenta}[Sending Tool Use: ${toolName}]${colors.reset}`);
console.log(`${colors.magenta}Input: ${JSON.stringify(toolInput)}${colors.reset}`);
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: {} }
2026-05-10 10:46:41 +02:00
})}\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) }
2026-05-10 10:46:41 +02:00
})}\n\n`);
res.write(`event: content_block_stop\ndata: ${JSON.stringify({
type: 'content_block_stop',
index: currentBlockIndex
})}\n\n`);
currentBlockIndex++;
}
}
if (data.message?.content) {
const text = data.message.content;
if (contentBlocks[currentBlockIndex] === undefined) {
res.write(`event: content_block_start\ndata: ${JSON.stringify({
type: 'content_block_start',
index: currentBlockIndex,
content_block: { type: 'text', text: '' }
2026-05-10 10:46:41 +02:00
})}\n\n`);
contentBlocks[currentBlockIndex] = '';
}
process.stdout.write(`${colors.green}${text}${colors.reset}`);
res.write(`event: content_block_delta\ndata: ${JSON.stringify({
type: 'content_block_delta',
index: currentBlockIndex,
delta: { type: 'text_delta', text }
2026-05-10 10:46:41 +02:00
})}\n\n`);
contentBlocks[currentBlockIndex] += text;
}
if (data.done) {
messageFinished = true;
if (contentBlocks[currentBlockIndex] !== undefined) {
res.write(`event: content_block_stop\ndata: ${JSON.stringify({
type: 'content_block_stop',
index: currentBlockIndex
})}\n\n`);
}
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 }
2026-05-10 10:46:41 +02:00
})}\n\n`);
res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
2026-05-10 10:46:41 +02:00
console.log(`${colors.green}${colors.reset}\n`);
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
processChunk(JSON.parse(trimmed));
} catch (e) {
console.error(`${colors.red}[Stream Parse Error] ${e.message}${colors.reset}`);
console.error(`${colors.red}${line}${colors.reset}`);
}
}
}
if (buffer.trim()) {
try {
processChunk(JSON.parse(buffer.trim()));
} catch (e) {
console.error(`${colors.red}[Final Buffer Parse Error] ${e.message}${colors.reset}`);
console.error(buffer);
}
}
if (!messageFinished) {
if (contentBlocks[currentBlockIndex] !== undefined) {
res.write(`event: content_block_stop\ndata: ${JSON.stringify({
type: 'content_block_stop',
index: currentBlockIndex
})}\n\n`);
}
res.write(`event: message_delta\ndata: ${JSON.stringify({
type: 'message_delta',
delta: { stop_reason: emittedToolUse ? 'tool_use' : 'end_turn' },
usage: { output_tokens: 0 }
2026-05-10 10:46:41 +02:00
})}\n\n`);
res.write(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`);
2026-05-10 10:46:41 +02:00
}
res.end();
}
// ── Proxy-Endpunkt ────────────────────────────────────────────────────────────
2026-05-10 10:46:41 +02:00
app.post('/v1/messages', async (req, res) => {
const requestNum = Date.now();
console.log(`${colors.magenta}━━━ #${requestNum} ━━━${colors.reset}`);
try {
const anthropicBody = req.body;
if (anthropicBody.model?.startsWith('claude-')) {
anthropicBody.model = OLLAMA_MODEL;
2026-05-10 10:46:41 +02:00
}
const ollamaBody = convertAnthropicToOllama(anthropicBody);
console.log(
`${colors.magenta}[msgs=${ollamaBody.messages.length}, tools=${ollamaBody.tools?.length || 0}, ctx=256k, think=false, model=${OLLAMA_MODEL}]${colors.reset}`
2026-05-10 10:46:41 +02:00
);
const response = await fetch(OLLAMA_URL, {
2026-05-10 10:46:41 +02:00
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OLLAMA_AUTH}`
2026-05-10 10:46:41 +02:00
},
body: JSON.stringify(ollamaBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error(`${colors.red}${errorText}${colors.reset}`);
throw new Error(`Ollama: ${response.status}`);
}
return handleResponse(response, anthropicBody, res, requestNum);
} catch (error) {
console.error(`${colors.red}${error.message}${colors.reset}`);
if (!res.headersSent) {
res.status(500).json({
type: 'error',
error: { type: 'api_error', message: error.message }
2026-05-10 10:46:41 +02:00
});
} else {
res.end();
}
}
});
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`);
2026-05-10 10:46:41 +02:00
});