From db2600560fb32d7d0d8da41114dbc86462fdea64 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 17 Apr 2026 11:50:24 -0400 Subject: [PATCH 1/2] Add local model support with health check and runner fallback --- README.md | 5 ++-- configs/llamacpp.yml | 10 ++++---- configs/lmstudio.yml | 6 ++--- configs/local.yml | 6 ++--- configs/vllm.yml | 6 ++--- scripts/install.sh | 12 ++++++---- src/commands/health.ts | 29 ++++++++++++++++++++++ src/core/health.ts | 53 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 2 ++ src/workflows/runner.ts | 47 +++++++++++++++++++++++++++++++++--- 10 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 src/commands/health.ts create mode 100644 src/core/health.ts diff --git a/README.md b/README.md index f63666b..0e68bff 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ cd /path/to/project && pi # Start coding /config apply gemini # smart=Gemini-3.1-Pro, general=Flash, fast=Flash-Lite /config apply deepseek # smart=DeepSeek-R1, general=V3, fast=V3 /config apply qwen # smart=Qwen3.5-235B, general=Qwen3.5-32B, fast=Qwen3.5-8B -/config apply local # Ollama: DeepSeek-Coder-V3 + Qwen2.5-Coder +/config apply local # Ollama: qwen3.6-36b + gemma4-26b /config apply lmstudio # LM Studio: same models, OpenAI-compatible API /config apply vllm # vLLM: high-throughput serving -/config apply llamacpp # llama.cpp: lightweight C++ inference +/config apply llamacpp # llama-swap: qwen3.6-36b (reasoning) + gemma4-26b (general) /config aliases # Show current mappings ``` @@ -110,6 +110,7 @@ Built-in skills for code quality and workflow authoring: | `/config apply ` | Switch model profile | | `/module show/hide ` | Toggle tool groups | | `/todo add/done/list/clear` | Manage session todos | +| `/health` | Check local model endpoint availability | | `/rtk` | Show RTK compression stats | | `/test-gen ` | Generate tests for a file or directory | | `/changelog [since]` | Generate changelog from git history | diff --git a/configs/llamacpp.yml b/configs/llamacpp.yml index ae33724..e22a072 100644 --- a/configs/llamacpp.yml +++ b/configs/llamacpp.yml @@ -1,9 +1,9 @@ -name: Local (llama.cpp) -description: Local models via llama.cpp server — lightweight C++ inference +name: Local (llama.cpp + llama-swap) +description: Local models via llama-swap — gemma4-26b for general tasks, qwen3.6-36b for reasoning configs: - name: smart - value: llamacpp/deepseek-coder-v3 + value: llamacpp/qwen3.6-36b - name: general - value: llamacpp/qwen2.5-coder-32b + value: llamacpp/gemma4-26b - name: fast - value: llamacpp/qwen2.5-coder-7b + value: llamacpp/gemma4-26b diff --git a/configs/lmstudio.yml b/configs/lmstudio.yml index 3be45c5..4308d59 100644 --- a/configs/lmstudio.yml +++ b/configs/lmstudio.yml @@ -2,8 +2,8 @@ name: Local (LM Studio) description: Local models via LM Studio — OpenAI-compatible API on localhost configs: - name: smart - value: lmstudio/deepseek-coder-v3 + value: lmstudio/qwen3.6-36b - name: general - value: lmstudio/qwen2.5-coder-32b + value: lmstudio/gemma4-26b - name: fast - value: lmstudio/qwen2.5-coder-7b + value: lmstudio/gemma4-26b diff --git a/configs/local.yml b/configs/local.yml index f89c5ba..5cdf443 100644 --- a/configs/local.yml +++ b/configs/local.yml @@ -2,8 +2,8 @@ name: Local (Ollama) description: Local models via Ollama — no API key needed configs: - name: smart - value: ollama/deepseek-coder-v3 + value: ollama/qwen3.6-36b - name: general - value: ollama/qwen2.5-coder-32b + value: ollama/gemma4-26b - name: fast - value: ollama/qwen2.5-coder-7b + value: ollama/gemma4-26b diff --git a/configs/vllm.yml b/configs/vllm.yml index bd83a33..d2d60dd 100644 --- a/configs/vllm.yml +++ b/configs/vllm.yml @@ -2,8 +2,8 @@ name: Local (vLLM) description: Local models via vLLM — high-throughput OpenAI-compatible serving configs: - name: smart - value: vllm/deepseek-coder-v3 + value: vllm/qwen3.6-36b - name: general - value: vllm/qwen2.5-coder-32b + value: vllm/gemma4-26b - name: fast - value: vllm/qwen2.5-coder-7b + value: vllm/gemma4-26b diff --git a/scripts/install.sh b/scripts/install.sh index baca282..c3c58ef 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -67,11 +67,13 @@ echo " Set API key: export ANTHROPIC_API_KEY=sk-ant-..." echo " Switch model: /config apply codex (or anthropic, gemini)" echo "" echo "Workflows:" -echo " /workflow brainstorm — Open ideation" echo " /workflow feature — Brainstorm → Plan → Implement" -echo " /workflow code-review — Analyze → Summarize" +echo " /workflow bugfix — Reproduce → Diagnose → Fix" echo " /workflow research — Clarify → Search → Analyze → Synthesize" -echo " /workflow test — Analyze → Generate → Run → Fix" echo " /workflow tri-review — 3-tier review → Consolidate" -echo " /workflow tri-dispatch — 3 models → Compare" -echo " /workflow self-improve — Metric-gated iteration" +echo " /workflow self-test — Run → Fix → Repeat" +echo "" +echo "Local models:" +echo " /config apply llamacpp — llama-swap (gemma4 + qwen3.6)" +echo " /config apply local — Ollama" +echo " /health — Check local endpoints" diff --git a/src/commands/health.ts b/src/commands/health.ts new file mode 100644 index 0000000..da97e0b --- /dev/null +++ b/src/commands/health.ts @@ -0,0 +1,29 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { checkEndpoint, formatHealth } from "../core/health.js"; + +/** Known local endpoints to check */ +const LOCAL_ENDPOINTS: Record = { + "llama.cpp / llama-swap": "http://localhost:8080/v1", + "Ollama": "http://localhost:11434/v1", + "LM Studio": "http://localhost:1234/v1", + "vLLM": "http://localhost:8000/v1", +}; + +export function registerHealth(pi: ExtensionAPI): void { + pi.registerCommand("health", { + description: "Check local model endpoint availability", + async handler(args, ctx) { + const lines: string[] = ["Local endpoint health check:", ""]; + + const results = await Promise.all( + Object.entries(LOCAL_ENDPOINTS).map(async ([name, url]) => { + const result = await checkEndpoint(url); + return `${name}: ${formatHealth(result)}`; + }), + ); + + lines.push(...results); + ctx.ui.notify(lines.join("\n"), "info"); + }, + }); +} diff --git a/src/core/health.ts b/src/core/health.ts new file mode 100644 index 0000000..2ec4008 --- /dev/null +++ b/src/core/health.ts @@ -0,0 +1,53 @@ +/** Health check for local model endpoints */ + +interface HealthResult { + ok: boolean; + endpoint: string; + models: string[]; + error?: string; + latencyMs: number; +} + +/** Ping an OpenAI-compatible /v1/models endpoint and return available model IDs */ +export async function checkEndpoint(baseUrl: string, timeoutMs = 3000): Promise { + const endpoint = baseUrl.replace(/\/+$/, ""); + const start = Date.now(); + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + const res = await fetch(`${endpoint}/models`, { + signal: controller.signal, + headers: { Authorization: "Bearer llamacpp" }, + }); + clearTimeout(timer); + + const latencyMs = Date.now() - start; + + if (!res.ok) { + return { ok: false, endpoint, models: [], error: `HTTP ${res.status}`, latencyMs }; + } + + const body = await res.json() as { data?: Array<{ id: string }> }; + const models = (body.data ?? []).map((m) => m.id); + + return { ok: true, endpoint, models, latencyMs }; + } catch (err) { + const latencyMs = Date.now() - start; + const message = err instanceof Error ? err.message : String(err); + const error = message.includes("abort") ? `Timeout after ${timeoutMs}ms` : message; + return { ok: false, endpoint, models: [], error, latencyMs }; + } +} + +/** Format a health result for display */ +export function formatHealth(result: HealthResult): string { + if (!result.ok) { + return `OFFLINE ${result.endpoint} — ${result.error}`; + } + const models = result.models.length > 0 + ? result.models.join(", ") + : "no models listed"; + return `ONLINE ${result.endpoint} (${result.latencyMs}ms) — ${models}`; +} diff --git a/src/index.ts b/src/index.ts index 620e3ba..d63d17a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { registerReviewPr } from "./commands/review-pr.js"; import { registerDecompose } from "./commands/decompose.js"; import { registerRepoMap } from "./commands/repo-map.js"; import { registerStatus } from "./commands/status.js"; +import { registerHealth } from "./commands/health.js"; import { registerStatusline } from "./statusline/git.js"; import { checkUpstreamAsync } from "./core/upstream.js"; import { registerSafetyCheck } from "./hooks/safety-check.js"; @@ -50,6 +51,7 @@ export default function pikit(pi: ExtensionAPI): void { registerDecompose(pi); registerRepoMap(pi); registerStatus(pi); + registerHealth(pi); // Modules (pass known module names) registerModuleCommands(pi, ["search", "memory", "todos"]); diff --git a/src/workflows/runner.ts b/src/workflows/runner.ts index ae6c503..34b7b6d 100644 --- a/src/workflows/runner.ts +++ b/src/workflows/runner.ts @@ -100,8 +100,12 @@ export async function runWorkflow( ctx.ui.notify(`Workflow "${workflow.name}" complete.`, "info"); } +/** Fallback order: smart -> general -> fast */ +const FALLBACK_CHAIN: readonly string[] = ["smart", "general", "fast"]; + /** * Execute a single step by sending its resolved prompt to the agent. + * If the primary model fails, tries the next tier in the fallback chain. * Returns the assistant's response text, or null if no prompt. */ async function executeStep( @@ -116,6 +120,33 @@ async function executeStep( try { return await executeStepInner(pi, step, input, memory, ctx); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`[${step.id}] Step failed: ${msg}. Trying fallback...`, "warning"); + + // Attempt fallback through remaining tiers + const currentTier = step.model ?? "smart"; + const startIdx = FALLBACK_CHAIN.indexOf(currentTier); + for (let i = Math.max(startIdx + 1, 0); i < FALLBACK_CHAIN.length; i++) { + const fallbackTier = FALLBACK_CHAIN[i]; + const fallbackId = resolveAlias(ctx.cwd, fallbackTier); + const allModels = ctx.modelRegistry.getAll(); + const fallbackModel = allModels.find((m) => m.id === fallbackId); + if (!fallbackModel || !ctx.modelRegistry.hasConfiguredAuth(fallbackModel)) continue; + + const ok = await pi.setModel(fallbackModel); + if (!ok) continue; + + ctx.ui.notify(`[${step.id}] Falling back to ${fallbackTier} (${fallbackId})`, "info"); + try { + return await executeStepInner(pi, step, input, memory, ctx); + } catch { + continue; + } + } + + ctx.ui.notify(`[${step.id}] All fallbacks exhausted. Skipping step.`, "error"); + return null; } finally { // Restore previous model if (previousModel) { @@ -141,13 +172,23 @@ async function switchModelForStep( // Find the target model const allModels = ctx.modelRegistry.getAll(); const target = allModels.find((m) => m.id === modelId); - if (!target) return null; + if (!target) { + ctx.ui.notify(`[${step.id}] Model "${modelId}" not found in registry. Running on current model.`, "warning"); + return null; + } // Check auth - if (!ctx.modelRegistry.hasConfiguredAuth(target)) return null; + if (!ctx.modelRegistry.hasConfiguredAuth(target)) { + ctx.ui.notify(`[${step.id}] Model "${modelId}" has no configured auth. Running on current model.`, "warning"); + return null; + } const ok = await pi.setModel(target); - return ok && currentModel ? currentModel : null; + if (!ok) { + ctx.ui.notify(`[${step.id}] Failed to switch to "${modelId}". Running on current model.`, "warning"); + return null; + } + return currentModel ?? null; } async function executeStepInner( From ac6efe3dc01b01894303dc6c2a80e433ec000149 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 17 Apr 2026 11:50:28 -0400 Subject: [PATCH 2/2] Fix security-patterns to warn not block, improve status and types --- src/commands/status.ts | 29 +++++++++++++++++++++++++++++ src/hooks/context-prune.ts | 13 ++++++++++--- src/hooks/security-patterns.ts | 34 +++++++++++++++++++--------------- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/commands/status.ts b/src/commands/status.ts index 0c57965..eea62fd 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { listWorkflows } from "../workflows/loader.js"; import { discoverSkills } from "../skills/loader.js"; +import { checkEndpoint, formatHealth } from "../core/health.js"; +import { activeProfile, getAliases } from "../config/profiles.js"; function checkCli(name: string): string | null { try { @@ -24,6 +26,33 @@ export function registerStatus(pi: ExtensionAPI): void { const cwd = ctx.cwd; const lines: string[] = ["## Pikit Status", ""]; + // Active model profile + const profile = activeProfile(cwd); + if (profile) { + const aliases = getAliases(cwd); + const aliasStr = Object.entries(aliases) + .map(([name, { current }]) => `${name}=${current}`) + .join(", "); + lines.push(`### Model Profile: ${profile}`); + lines.push(aliasStr); + lines.push(""); + } + + // Local endpoint check + const localEndpoints = [ + ["llama-swap", "http://localhost:8080/v1"], + ["Ollama", "http://localhost:11434/v1"], + ] as const; + const endpointResults = await Promise.all( + localEndpoints.map(async ([name, url]) => { + const result = await checkEndpoint(url, 1500); + return `- **${name}**: ${formatHealth(result)}`; + }), + ); + lines.push("### Local Endpoints"); + lines.push(...endpointResults); + lines.push(""); + // CLIs lines.push("### External CLIs"); const clis: Array<[string, string, string]> = [ diff --git a/src/hooks/context-prune.ts b/src/hooks/context-prune.ts index ee2184b..c026372 100644 --- a/src/hooks/context-prune.ts +++ b/src/hooks/context-prune.ts @@ -45,16 +45,23 @@ export function registerContextPrune(pi: ExtensionAPI): void { }); } -function pruneToolResults(messages: Array>, beforeIndex: number): void { +interface PrunableMessage { + type?: string; + message?: { + role?: string; + content?: Array<{ type: string; text?: string }>; + }; +} + +function pruneToolResults(messages: PrunableMessage[], beforeIndex: number): void { for (let i = 0; i < beforeIndex; i++) { - const msg = messages[i] as { type?: string; message?: { role?: string; content?: Array<{ type: string; text?: string }> } }; + const msg = messages[i]; if (msg.type !== "message") continue; if (msg.message?.role !== "toolResult") continue; const content = msg.message.content; if (!Array.isArray(content)) continue; - // Replace long text blocks with a short summary for (let j = 0; j < content.length; j++) { const block = content[j]; if (block.type === "text" && block.text && block.text.length > 500) { diff --git a/src/hooks/security-patterns.ts b/src/hooks/security-patterns.ts index 390eced..6e5e387 100644 --- a/src/hooks/security-patterns.ts +++ b/src/hooks/security-patterns.ts @@ -1,4 +1,4 @@ -import { type ExtensionAPI, isToolCallEventType } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; /** * Security patterns hook — catches vulnerability patterns at edit time. @@ -75,20 +75,24 @@ function checkContent(filePath: string, content: string): string | undefined { } export function registerSecurityPatterns(pi: ExtensionAPI): void { - pi.on("tool_call", (event, _ctx) => { - if (isToolCallEventType("write", event)) { - const warning = checkContent(event.input.path, event.input.content); - if (warning) { - return { block: true, reason: warning }; - } - } - - if (isToolCallEventType("edit", event)) { - const combined = event.input.edits.map((e) => e.newText).join("\n"); - const warning = checkContent(event.input.path, combined); - if (warning) { - return { block: true, reason: warning }; - } + pi.on("tool_result", (event, _ctx) => { + if (event.toolName !== "write" && event.toolName !== "edit") return; + + const input = event.input as { path?: string; content?: string; edits?: Array<{ newText?: string }> }; + const filePath = input.path ?? ""; + const content = event.toolName === "write" + ? (input.content ?? "") + : (input.edits ?? []).map((e) => e.newText ?? "").join("\n"); + + const warning = checkContent(filePath, content); + if (warning) { + const existing = event.content ?? []; + return { + content: [ + ...existing, + { type: "text" as const, text: `\n[security] ${warning}` }, + ], + }; } }); }