From 2ebba07bb0933884521c84db099dda771b991a34 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Sat, 18 Apr 2026 20:45:14 +0200 Subject: [PATCH] feat: add claude-code LLM provider + update dashboard to fo-blog-v5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client.ts: add claude-code provider routing BLOG_LLM_PROVIDER=claude-code to claude-bridge (flat-rate, no API billing via Claude Code subscription) - checkHealth() now pings /health on claude-bridge for real availability check - Default OLLAMA_LLM_MODEL changed from qwen2.5:14b to fo-blog-v5 - Dashboard: add claude-code card (EMPFOHLEN), rename fo-blog-v3 → fo-blog-v5 - loadBlogLLMStatus() handles all 3 providers: claude-code/anthropic/ollama - Grid expanded from 3 to 4 columns to accommodate new card - ecosystem.config.js + .env on Erik: OLLAMA_LLM_MODEL=fo-blog-v5 confirmed --- CHANGELOG_PENDING.md | 1 + packages/api/src/llm/client.ts | 70 +++++++++++++++++++++--- packages/dashboard/index.html | 99 +++++++++++++++++++++++++--------- 3 files changed, 140 insertions(+), 30 deletions(-) diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 91b1062..9036a27 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -3,6 +3,7 @@ Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` Types: FEAT · FIX · UI · DATA · AI · INFRA +{"d":"2026-04-18","t":"AI","m":"Blog LLM: claude-code provider implemented in packages/api/src/llm/client.ts — routes BLOG_LLM_PROVIDER=claude-code to claude-bridge (http://localhost:3250/api/generate) on Erik using Claude Code flat-rate subscription. No API billing. checkHealth() pings /health endpoint. Dashboard updated: added claude-code card (EMPFOHLEN, AKTIV), fo-blog-v3-qwen7b card replaced with fo-blog-v5, loadBlogLLMStatus() now handles claude-code provider with correct badge/border highlighting. ecosystem.config.js + .env updated: OLLAMA_LLM_MODEL=fo-blog-v5, BLOG_LLM_PROVIDER=claude-code confirmed active via pm2 env."} {"d":"2026-04-18","t":"FIX","m":"Cloudflare Tunnel DNS mass-update: after deleting phantom eo-pulse tunnel and creating main-prod (90c22eb0), 31 context-x.org + 7 fichtmueller.org DNS records still pointed to the deleted 641c39a5 tunnel → 530 on all services. Bulk-patched via Cloudflare API: all records now point to main-prod. Created missing admin.magatama.fichtmueller.org CNAME. TIP cloudflared-tip.service restart policy changed to Restart=always (was on-failure, so clean exits caused permanent outage). peercortex.org remains 530 — DNS is in a separate inaccessible Cloudflare account (NS: fattouche/elisabeth.ns.cloudflare.com); needs manual login."} {"d":"2026-04-18","t":"DATA","m":"Image backfill: GBICS og:image + QSFPTEK backfill scripts run on Erik — 226 new images added (671 → 897 total, 17.5% → 23.4% coverage). OSFP form factor: 0 → 68 images. QSFPTEK og:image URL bug fixed (double-hostname prefix stripped). OSFP-DR8-800G manually set to GBICS-compatible image (cdn11.bigcommerce.com DR8 product photo)."} {"d":"2026-04-18","t":"FIX","m":"FS.com scraper: all 247 prices written as €79 (wrong) — root cause: 'Gratis Versand ab 79 € (ohne MwSt.)' free-shipping banner appears on every FS.com product page. PRICE_QUALIFIED bodyText regex matched this banner text before reaching the actual product price. Fix: (1) DOM-based price extraction added to page.evaluate — targets [class*='price-value']/[class*='product-price'] etc., skipping elements inside shipping/banner/footer parents; (2) bodyText qualified patterns now check 200-char context for versand/shipping/gratis keywords and skip matches that appear in shipping context; (3) waitForSelector for price elements added before evaluate; (4) deleted 247 invalid €79 observations from DB."} diff --git a/packages/api/src/llm/client.ts b/packages/api/src/llm/client.ts index 7f064eb..827462a 100644 --- a/packages/api/src/llm/client.ts +++ b/packages/api/src/llm/client.ts @@ -1,20 +1,23 @@ /** - * LLM client for blog generation — supports Ollama (local) and Anthropic Claude (API). + * LLM client for blog generation — supports Ollama (local), Anthropic Claude (API), + * and Claude-Code (flat-rate via claude-bridge on Erik). * * Provider selection: - * BLOG_LLM_PROVIDER=anthropic → Claude Sonnet/Haiku via Anthropic API - * BLOG_LLM_PROVIDER=ollama → qwen2.5 on local Ollama (default) + * BLOG_LLM_PROVIDER=claude-code → Claude via claude-bridge (flat-rate, recommended) + * BLOG_LLM_PROVIDER=anthropic → Claude Sonnet/Haiku via Anthropic API + * BLOG_LLM_PROVIDER=ollama → fine-tuned fo-blog-v5 on local Ollama (default) * - * Claude is strongly recommended for blog generation — qwen2.5:14b cannot - * follow complex multi-constraint prompts (mode collapse). + * Claude-code is preferred: uses Claude Code subscription (flat-rate), no API costs. + * Ollama fo-blog-v5 is the fallback for offline/local usage. */ const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; -const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "qwen2.5:14b"; +const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "fo-blog-v5"; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ""; const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514"; const BLOG_LLM_PROVIDER = process.env.BLOG_LLM_PROVIDER || "ollama"; +const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:3250"; interface LlmResponse { text: string; @@ -222,6 +225,48 @@ async function generateOllama( }); } +// ═══════════════════════════════════════════════════════ +// CLAUDE-CODE PROVIDER (claude-bridge — flat-rate via Claude Code subscription) +// ═══════════════════════════════════════════════════════ + +async function generateClaudeBridge( + systemPrompt: string, + userPrompt: string, + options?: { temperature?: number; maxTokens?: number; timeoutMs?: number }, +): Promise { + const startTime = Date.now(); + // claude-bridge expects combined prompt — system + user joined with double newline + const fullPrompt = `${systemPrompt}\n\n${userPrompt}`; + + const resp = await fetch(`${CLAUDE_BRIDGE_URL}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: fullPrompt }), + signal: AbortSignal.timeout(options?.timeoutMs ?? 300000), + }); + + if (!resp.ok) { + const errText = await resp.text(); + throw new Error(`Claude bridge failed: ${resp.status} ${errText.slice(0, 200)}`); + } + + const data = await resp.json() as { success: boolean; content?: string; error?: string }; + + if (!data.success || !data.content) { + throw new Error(`Claude bridge returned empty response: ${JSON.stringify(data)}`); + } + + const duration = Date.now() - startTime; + console.log(`[LLM] Claude-bridge: ${data.content.length} chars, ${duration}ms`); + + return { + text: data.content, + model: "claude-code", + totalDuration: duration * 1_000_000, // ns for compat with Ollama callers + evalCount: Math.ceil(data.content.length / 4), // approx tokens + }; +} + // ═══════════════════════════════════════════════════════ // PUBLIC API — auto-routes to configured provider // ═══════════════════════════════════════════════════════ @@ -231,6 +276,9 @@ export async function generate( userPrompt: string, options?: { temperature?: number; maxTokens?: number; timeoutMs?: number }, ): Promise { + if (BLOG_LLM_PROVIDER === "claude-code") { + return generateClaudeBridge(systemPrompt, userPrompt, options); + } if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { return generateClaude(systemPrompt, userPrompt, options); } @@ -281,6 +329,16 @@ export async function chat( /** Check if configured LLM provider is available */ export async function checkHealth(): Promise<{ ok: boolean; model: string; provider: string; error?: string }> { + if (BLOG_LLM_PROVIDER === "claude-code") { + try { + const resp = await fetch(`${CLAUDE_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) return { ok: false, model: "claude-code", provider: "claude-code", error: `HTTP ${resp.status}` }; + return { ok: true, model: "claude-code", provider: "claude-code" }; + } catch (err) { + return { ok: false, model: "claude-code", provider: "claude-code", error: (err as Error).message }; + } + } + if (BLOG_LLM_PROVIDER === "anthropic" && ANTHROPIC_API_KEY) { // Key presence check only — live API call causes 429 when pipeline is running return { ok: true, model: ANTHROPIC_MODEL, provider: "anthropic" }; diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index ac63871..dff5140 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -1243,23 +1243,48 @@ -
+
- + +
+
+
+
🤖 claude-code
+
claude-bridge / Erik
+
+
+ ★ EMPFOHLEN + +
+
+
+
★★★★★ Blog-Qualität
+
★★★★ Geschwindigkeit
+
✓ Kein Mode Collapse
+
✓ Flat-rate (kein API-Billing)
+
✓ Claude Code Subscription
+
+
+ BLOG_LLM_PROVIDER=claude-code
CLAUDE_BRIDGE_URL=http://localhost:3250
+
+
checking…
+
+ +
🧠 claude-sonnet-4-6
Anthropic API
- ★ EMPFOHLEN +
★★★★★ Blog-Qualität
★★★★ Geschwindigkeit
✓ Komplexe Multi-Constraint Prompts
✓ Kein Mode Collapse
-
✓ 4096 Token Output
+
⚠ API-Kosten pro Artikel
BLOG_LLM_PROVIDER=anthropic
ANTHROPIC_MODEL=claude-sonnet-4-6
@@ -1267,11 +1292,11 @@
checking…
- +
-
🎯 fo-blog-v3-qwen7b
+
🎯 fo-blog-v5
Ollama / Mac Studio
@@ -1279,12 +1304,12 @@
★★★★ Blog-Qualität
★★★★★ Geschwindigkeit
-
✓ Fine-tuned auf TIP-Stil
-
✓ Lokal / kein API-Kosten
+
✓ Fine-tuned auf TIP-Stil (v5)
+
✓ Lokal / keine API-Kosten
⚠ Gelegentlicher Mode Collapse
- BLOG_LLM_PROVIDER=ollama
OLLAMA_LLM_MODEL=fo-blog-v3-qwen7b
+ BLOG_LLM_PROVIDER=ollama
OLLAMA_LLM_MODEL=fo-blog-v5
checking…
@@ -1316,7 +1341,7 @@
Modell wechseln: SSH → Erik → nano /opt/tip/ecosystem.config.js - → BLOG_LLM_PROVIDER + ANTHROPIC_API_KEY → + → BLOG_LLM_PROVIDER + CLAUDE_BRIDGE_URL / ANTHROPIC_API_KEY → pm2 restart tip-api --update-env
@@ -4934,7 +4959,8 @@ async function loadBlogLLMStatus() { } if (activeModel) activeModel.textContent = llm.model || '—'; if (activeProvider) { - activeProvider.textContent = llm.provider === 'anthropic' ? 'anthropic' : 'ollama'; + var provLabel = llm.provider === 'claude-code' ? 'claude-code' : llm.provider === 'anthropic' ? 'anthropic' : 'ollama'; + activeProvider.textContent = provLabel; activeProvider.style.background = 'var(--accent)'; activeProvider.style.color = '#fff'; } @@ -4943,35 +4969,60 @@ async function loadBlogLLMStatus() { queueEl.textContent = q > 0 ? 'Queue: ' + q + ' Jobs' : 'Queue: idle'; } - // Mark active model card with accent border - var foActive = document.getElementById('blog-model-fo-active'); - var foCard = document.getElementById('blog-model-card-fo'); - var claudeCard = document.getElementById('blog-model-card-claude'); + // Reset all card borders + active badges + ['cc','claude','fo'].forEach(function(k) { + var card = document.getElementById('blog-model-card-' + k); + if (card) card.style.border = '2px solid var(--border)'; + var badge2 = document.getElementById('blog-model-' + k + '-active'); + if (badge2) badge2.style.display = 'none'; + }); - if (llm.provider === 'anthropic') { + if (llm.provider === 'claude-code') { + var ccCard = document.getElementById('blog-model-card-cc'); + if (ccCard) ccCard.style.border = '2px solid var(--accent)'; + var ccActive = document.getElementById('blog-model-cc-active'); + if (ccActive) ccActive.style.display = 'inline'; + var ccSt = document.getElementById('blog-model-cc-status'); + if (ccSt) { + ccSt.textContent = llm.ok ? '● Aktiv — claude-bridge erreichbar' : '⚠ claude-bridge nicht erreichbar: ' + (llm.error || '').slice(0, 60); + ccSt.style.color = llm.ok ? '#1a7a3a' : '#b45309'; + ccSt.style.fontWeight = '600'; + } + var clSt2 = document.getElementById('blog-model-claude-status'); + if (clSt2) { clSt2.textContent = 'bereit (nicht aktiv)'; clSt2.style.color = 'var(--text-dim)'; clSt2.style.fontWeight = '400'; } + var foSt2 = document.getElementById('blog-model-fo-status'); + if (foSt2) { foSt2.textContent = 'bereit (nicht aktiv)'; foSt2.style.color = 'var(--text-dim)'; foSt2.style.fontWeight = '400'; } + } else if (llm.provider === 'anthropic') { + var claudeCard = document.getElementById('blog-model-card-claude'); if (claudeCard) claudeCard.style.border = '2px solid var(--accent)'; + var claudeActive = document.getElementById('blog-model-claude-active'); + if (claudeActive) claudeActive.style.display = 'inline'; var claudeStatusEl = document.getElementById('blog-model-claude-status'); if (claudeStatusEl) { claudeStatusEl.textContent = '● Aktiv — API-Key konfiguriert'; claudeStatusEl.style.color = '#1a7a3a'; claudeStatusEl.style.fontWeight = '600'; } - var foSt = document.getElementById('blog-model-fo-status'); - if (foSt) { foSt.textContent = 'bereit (nicht aktiv)'; foSt.style.color = 'var(--text-dim)'; } + var ccSt2 = document.getElementById('blog-model-cc-status'); + if (ccSt2) { ccSt2.textContent = 'bereit (nicht aktiv)'; ccSt2.style.color = 'var(--text-dim)'; ccSt2.style.fontWeight = '400'; } + var foSt3 = document.getElementById('blog-model-fo-status'); + if (foSt3) { foSt3.textContent = 'bereit (nicht aktiv)'; foSt3.style.color = 'var(--text-dim)'; foSt3.style.fontWeight = '400'; } } else { - if (foActive) foActive.style.display = 'inline'; + // ollama + var foCard = document.getElementById('blog-model-card-fo'); if (foCard) foCard.style.border = '2px solid var(--accent)'; + var foActive = document.getElementById('blog-model-fo-active'); + if (foActive) foActive.style.display = 'inline'; var foStatusEl = document.getElementById('blog-model-fo-status'); if (foStatusEl) { foStatusEl.textContent = llm.ok ? '● Aktiv — Ollama erreichbar' : '⚠ Ollama nicht erreichbar: ' + (llm.error || '').slice(0, 60); foStatusEl.style.color = llm.ok ? '#1a7a3a' : '#b45309'; foStatusEl.style.fontWeight = '600'; } - var clSt = document.getElementById('blog-model-claude-status'); - if (clSt) { - clSt.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + ANTHROPIC_API_KEY setzen'; - clSt.style.color = 'var(--text-dim)'; - } + var ccSt3 = document.getElementById('blog-model-cc-status'); + if (ccSt3) { ccSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=claude-code setzen'; ccSt3.style.color = 'var(--text-dim)'; ccSt3.style.fontWeight = '400'; } + var clSt3 = document.getElementById('blog-model-claude-status'); + if (clSt3) { clSt3.textContent = 'bereit — BLOG_LLM_PROVIDER=anthropic + API-Key setzen'; clSt3.style.color = 'var(--text-dim)'; clSt3.style.fontWeight = '400'; } } } catch(e) { var b = document.getElementById('blog-llm-status-badge');