From 40a4107bc929887e836df0e26b053900975c0cb2 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Fri, 7 Aug 2026 19:10:32 -0400 Subject: [PATCH] Integrate OpenCode CLI with ollama-cloud/gemma4:31b --- bot.py | 25 +++--- opencode_llm.py | 200 ++++++++++++++++++++++++++++++++++++++++++++++++ selftest.py | 10 +-- 3 files changed, 217 insertions(+), 18 deletions(-) create mode 100644 opencode_llm.py diff --git a/bot.py b/bot.py index 1f220fe..a4ea075 100644 --- a/bot.py +++ b/bot.py @@ -153,14 +153,14 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--llm-engine", - choices=["ollama", "opencode", "apple", "macos", "claude"], - default="ollama", - help="LLM engine to use: ollama/opencode for Ollama/Cloud models, apple/macos for local MLX, claude for Claude Code.", + choices=["opencode", "ollama", "apple", "macos", "claude"], + default="opencode", + help="LLM engine to use: opencode/ollama for OpenCode Cloud models (gemma4:31b), apple/macos for local MLX, claude for Claude Code.", ) parser.add_argument( - "--ollama-model", - default="gemma4:31b", - help="Ollama / OpenCode model name (default gemma4:31b).", + "--opencode-model", + default="ollama-cloud/gemma4:31b", + help="OpenCode Cloud model (default ollama-cloud/gemma4:31b).", ) parser.add_argument( "--ollama-host", @@ -497,18 +497,17 @@ def build_claude_options(args: argparse.Namespace, vocabulary=None, brain=None) def build_llm(args: argparse.Namespace, vocabulary=None, brain=None, observer=None): - if args.llm_engine in ("ollama", "opencode"): - from ollama_llm import OllamaLLM, probe_ollama, get_default_ollama_host + if args.llm_engine in ("opencode", "ollama"): + from opencode_llm import OpenCodeLLM, probe_opencode - host = args.ollama_host or get_default_ollama_host() - logger.info(f"LLM: Ollama / OpenCode ({args.ollama_model} @ {host})") + available, reason = probe_opencode(args.opencode_model) + logger.info(f"LLM: OpenCode Cloud ({reason})") personality = read_personality(args.cwd) or "You are a helpful voice assistant." system_prompt = personality + "\n\n" + VOICE_STYLE if brain and (memory := brain.prompt_block()): system_prompt += "\n\n" + memory - return OllamaLLM( - model=args.ollama_model, - host=host, + return OpenCodeLLM( + model=args.opencode_model, system_prompt=system_prompt, observer=observer, ) diff --git a/opencode_llm.py b/opencode_llm.py new file mode 100644 index 0000000..6aa0ccf --- /dev/null +++ b/opencode_llm.py @@ -0,0 +1,200 @@ +"""A Pipecat processor that puts OpenCode (with ollama-cloud/gemma4:31b) in the LLM slot. + +Drives the installed OpenCode CLI (`opencode run -m ollama-cloud/gemma4:31b`) to stream +cloud responses to text-to-speech downstream with zero local GPU overhead. +""" + +import asyncio +import json +import os +import re +import shutil +from pathlib import Path +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + Frame, + InterruptionFrame, + LLMContextFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, + LLMTextFrame, + StartFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor + +_NOISE_TRANSCRIPTS = { + "", + ".", + "thank you.", + "thanks for watching!", + "you", + "bye.", + "okay.", + "[blank_audio]", + "[silence]", +} + + +def _clean_spoken_text(text: str) -> str: + """Clean text for speech output and truncate fake turn generations.""" + if not text: + return "" + # Truncate if model hallucinates fake turn markers + for marker in ("User:", "Human:", "Assistant:", "\nUser", "\nHuman", "\nAssistant"): + if marker in text: + text = text.split(marker)[0] + # Remove markdown code blocks + text = re.sub(r"```[\s\S]*?```", "", text) + # Remove inline code ticks + text = re.sub(r"`[^`]*`", "", text) + # Remove markdown syntax characters + text = re.sub(r"[\#\*\_\~]", "", text) + # Flatten newlines into clear speech + lines = [line.strip() for line in text.splitlines() if line.strip()] + return " ".join(lines).strip() + + +def find_opencode_cli() -> str | None: + return shutil.which("opencode") or ( + "/Users/adolforeyna/.opencode/bin/opencode" + if os.path.exists("/Users/adolforeyna/.opencode/bin/opencode") + else None + ) + + +def probe_opencode(model: str = "ollama-cloud/gemma4:31b") -> tuple[bool, str]: + cli = find_opencode_cli() + if not cli: + return False, "OpenCode CLI binary not found" + return True, f"OpenCode CLI available ({cli}) with model {model}" + + +class OpenCodeLLM(FrameProcessor): + """Runs user turns through the OpenCode CLI driving OpenCode Cloud models.""" + + def __init__( + self, + *, + model: str = "ollama-cloud/gemma4:31b", + system_prompt: str | None = None, + observer=None, + **kwargs, + ): + super().__init__(**kwargs) + self._model = model + self._system_prompt = system_prompt or "You are a helpful spoken voice assistant. Keep answers brief and conversational." + self._on_reply = observer + self._turn_task: asyncio.Task | None = None + self._history: list[dict[str, str]] = [] + self._cli_path = find_opencode_cli() or "opencode" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + + if isinstance(frame, StartFrame): + await self.push_frame(frame, direction) + logger.info(f"OpenCode LLM engine ready: CLI={self._cli_path}, model={self._model}") + elif isinstance(frame, (EndFrame, CancelFrame)): + await self._cancel_turn() + await self.push_frame(frame, direction) + elif isinstance(frame, InterruptionFrame): + await self._cancel_turn() + await self.push_frame(frame, direction) + elif isinstance(frame, LLMContextFrame): + text = self._latest_user_text(frame.context) + await self._maybe_start_turn(text) + else: + await self.push_frame(frame, direction) + + def _latest_user_text(self, context) -> str: + if not context or not hasattr(context, "messages"): + return "" + for msg in reversed(context.messages): + if isinstance(msg, dict) and msg.get("role") == "user": + content = msg.get("content", "") + if isinstance(content, str): + return content + elif isinstance(content, list): + text_parts = [c.get("text", "") for c in content if isinstance(c, dict)] + return " ".join(text_parts) + return "" + + async def _maybe_start_turn(self, text: str): + utterance = text.strip() + if utterance.lower() in _NOISE_TRANSCRIPTS or len(utterance) < 2: + logger.debug(f"Ignoring noise transcript: {utterance!r}") + return + + await self._cancel_turn() + logger.info(f"You: {utterance}") + self._turn_task = self.create_task(self._run_turn(utterance)) + + async def _cancel_turn(self): + if not self._turn_task: + return + task, self._turn_task = self._turn_task, None + await self.cancel_task(task) + + async def _run_turn(self, utterance: str): + self._history.append({"role": "user", "content": utterance}) + + # Format prompt with system instructions and recent conversation history + recent_history = self._history[-6:] + conv_text = "\n".join( + f"{'User' if m['role']=='user' else 'Assistant'}: {m['content']}" + for m in recent_history + ) + prompt_str = f"{self._system_prompt}\n\n{conv_text}\nAssistant:" + + await self.push_frame(LLMFullResponseStartFrame()) + chunks: list[str] = [] + + try: + proc = await asyncio.create_subprocess_exec( + self._cli_path, + "run", + "-m", self._model, + "--pure", + prompt_str, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.DEVNULL, + ) + + while True: + line = await proc.stdout.readline() + if not line: + break + text_line = line.decode("utf-8") + cleaned = _clean_spoken_text(text_line) + if cleaned: + chunks.append(cleaned) + await self.push_frame(LLMTextFrame(cleaned)) + + await proc.wait() + + except asyncio.CancelledError: + logger.info("OpenCode turn cancelled mid-response.") + if proc and proc.returncode is None: + try: + proc.kill() + except Exception: + pass + raise + except Exception as e: + logger.error(f"OpenCode LLM error: {e}") + err_msg = "Sorry, I ran into an error generating a response." + chunks.append(err_msg) + await self.push_frame(LLMTextFrame(err_msg)) + finally: + await self.push_frame(LLMFullResponseEndFrame()) + + full_reply = _clean_spoken_text(" ".join(chunks)) + if full_reply: + self._history.append({"role": "assistant", "content": full_reply}) + logger.info(f"OpenCode LLM ({self._model}): {full_reply}") + if self._on_reply: + self._on_reply(full_reply) diff --git a/selftest.py b/selftest.py index 66112cf..bba1ab8 100755 --- a/selftest.py +++ b/selftest.py @@ -171,11 +171,11 @@ def check_mlx_whisper(synthesized): record("mlx whisper (optional)", matches(heard), f"heard {heard!r}") -async def check_ollama_llm(): - from ollama_llm import probe_ollama +def check_opencode_llm(): + from opencode_llm import probe_opencode - available, reason = await probe_ollama(model="gemma4:31b") - record("ollama llm (optional)", available, reason) + available, reason = probe_opencode(model="ollama-cloud/gemma4:31b") + record("opencode llm", available, reason) def check_macos_llm(): @@ -238,7 +238,7 @@ async def main(): check_apple_stt(synthesized) check_mlx_whisper(synthesized) check_macos_llm() - await check_ollama_llm() + check_opencode_llm() await check_claude() required_failures = [