From 0b0de97363121644b1b9b7aebec5dda5f3ae8f27 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Sat, 15 Aug 2026 23:31:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(ollama):=20Anthropic=E2=86=92Ollama=20?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=20adapter=20+=20launchd/systemd=20?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myagents/cli.py | 2 + myagents/commands/ollama.py | 326 ++++++++++++++++++++++++++++++++++++ myagents/ollama_adapter.py | 178 ++++++++++++++++++++ tests/test_ollama.py | 119 +++++++++++++ 4 files changed, 625 insertions(+) create mode 100644 myagents/commands/ollama.py create mode 100644 myagents/ollama_adapter.py create mode 100644 tests/test_ollama.py diff --git a/myagents/cli.py b/myagents/cli.py index fa9b6c1..4befde1 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -7,6 +7,7 @@ import click from myagents.commands import update_cmd, upgrade_cmd from myagents.commands.completion import build_completion_group from myagents.commands.ensure_agent import ensure_agent_cmd +from myagents.commands.ollama import ollama_cmd from myagents.launcher import build_cli @@ -68,6 +69,7 @@ cli.add_command(build_cli("cursor"), name="cursor") cli.add_command(ensure_agent_cmd) cli.add_command(update_cmd) cli.add_command(upgrade_cmd, name="upgrade") +cli.add_command(ollama_cmd) cli.add_command(build_completion_group(_progs)) diff --git a/myagents/commands/ollama.py b/myagents/commands/ollama.py new file mode 100644 index 0000000..6305f18 --- /dev/null +++ b/myagents/commands/ollama.py @@ -0,0 +1,326 @@ +"""``ollama`` — manage the local Ollama adapter (Claude Code <-> Ollama). + +Claude Code talks Anthropic protocol; Ollama serves it natively on +``/v1/messages`` but rejects mid-conversation ``role: system`` messages +(Qwen chat templates require a leading system message). The adapter in +:mod:`myagents.ollama_adapter` normalizes requests before forwarding. + +The adapter runs as a persistent service on 127.0.0.1:8199; this command +manages the platform service that keeps it alive: + + myagents ollama up # install + start the service + myagents ollama down # stop and remove the service + myagents ollama status # service / health state + +Platform backends: + macOS — launchd agent (LaunchAgents + ``launchctl bootstrap``) + Linux — systemd user unit (``systemctl --user enable --now``) + Windows— not supported yet +""" + +from __future__ import annotations + +import contextlib +import os +import subprocess +import sys +import time +import urllib.request +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +import click +from rich.console import Console + +from myagents.ollama_adapter import DEFAULT_PORT + +console = Console() + +_HEALTH = f"http://127.0.0.1:{DEFAULT_PORT}/health" +_UID = os.getuid() +_LOG_DIR = Path.home() / ".xiaohe" / "logs" + + +def health_ok() -> bool: + """True when the adapter endpoint responds.""" + try: + with urllib.request.urlopen(_HEALTH, timeout=1) as resp: + return resp.status == 200 + except OSError: + return False + + +# ── macOS: launchd ─────────────────────────────────────────────────────── + +LABEL = "team.xiaohe.ollama-adapter" +_PLIST_DIR = Path.home() / "Library" / "LaunchAgents" +PLIST_PATH = _PLIST_DIR / f"{LABEL}.plist" + + +def _mac_plist_text() -> str: + return f""" + + + + Label + {LABEL} + ProgramArguments + + {sys.executable} + -m + myagents.ollama_adapter + + RunAtLoad + + KeepAlive + + WorkingDirectory + {Path.home()} + StandardOutPath + {_LOG_DIR / "ollama-adapter.out.log"} + StandardErrorPath + {_LOG_DIR / "ollama-adapter.err.log"} + + +""" + + +def _mac_loaded() -> bool: + result = subprocess.run( + ["launchctl", "print", f"gui/{_UID}/{LABEL}"], + capture_output=True, + ) + return result.returncode == 0 + + +def _mac_write() -> None: + _PLIST_DIR.mkdir(parents=True, exist_ok=True) + _LOG_DIR.mkdir(parents=True, exist_ok=True) + PLIST_PATH.write_text(_mac_plist_text(), encoding="utf-8") + + +def _mac_start() -> None: + if _mac_loaded(): + return + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{_UID}", str(PLIST_PATH)], + capture_output=True, + text=True, + ) + if result.returncode != 0 and not _mac_loaded(): + raise click.ClickException( + f"launchctl bootstrap failed: {result.stderr.strip()}" + ) + + +def _mac_stop() -> None: + if _mac_loaded(): + subprocess.run( + ["launchctl", "bootout", f"gui/{_UID}/{LABEL}"], + capture_output=True, + ) + + +# ── Linux: systemd user unit ───────────────────────────────────────────── + +UNIT_NAME = "xiaohe-ollama-adapter.service" +UNIT_DIR = Path.home() / ".config" / "systemd" / "user" +UNIT_PATH = UNIT_DIR / UNIT_NAME + + +def _sysd_unit_text() -> str: + return f"""[Unit] +Description=xiaohe ollama adapter (Claude Code <-> Ollama bridge) +After=network-online.target + +[Service] +Type=simple +ExecStart={sys.executable} -m myagents.ollama_adapter +Restart=always +RestartSec=2 +WorkingDirectory={Path.home()} + +[Install] +WantedBy=default.target +""" + + +def _systemctl(*args: str, check: bool = False) -> subprocess.CompletedProcess: + result = subprocess.run( + ["systemctl", "--user", *args], capture_output=True, text=True + ) + if check and result.returncode != 0: + raise click.ClickException( + f"systemctl --user {' '.join(args)} failed: {result.stderr.strip()}" + ) + return result + + +def _sysd_state() -> tuple[str, str]: + """Return (enabled|disabled|static|not-found, active|inactive|failed).""" + enabled = _systemctl("is-enabled", UNIT_NAME).stdout.strip() + active = _systemctl("is-active", UNIT_NAME).stdout.strip() + return enabled or "unknown", active or "unknown" + + +def _sysd_write() -> None: + UNIT_DIR.mkdir(parents=True, exist_ok=True) + UNIT_PATH.write_text(_sysd_unit_text(), encoding="utf-8") + _systemctl("daemon-reload", check=True) + + +def _sysd_start() -> None: + _sysd_write() + # Best-effort linger so the service survives logout (needs no login + # session); harmless when it fails on systems without logind. + with contextlib.suppress(OSError): + subprocess.run( + ["loginctl", "enable-linger", os.environ.get("USER", "")], + capture_output=True, + ) + _systemctl("enable", "--now", UNIT_NAME, check=True) + + +def _sysd_stop() -> None: + _systemctl("disable", "--now", UNIT_NAME) + if UNIT_PATH.exists(): + UNIT_PATH.unlink() + _systemctl("daemon-reload") + + +# ── platform dispatch ──────────────────────────────────────────────────── + + +@dataclass +class _Backend: + name: str + service_path: Path + is_loaded: Callable[[], bool] + write: Callable[[], None] + start: Callable[[], None] + stop: Callable[[], None] + + +def _platform() -> str: + """Return ``sys.platform`` (function so Pyright can't constant-fold it).""" + return sys.platform + + +def _backend() -> _Backend: + if _platform() == "darwin": + return _Backend( + name="launchd", + service_path=PLIST_PATH, + is_loaded=_mac_loaded, + write=_mac_write, + start=_mac_start, + stop=_mac_stop, + ) + if _platform() == "linux": + return _Backend( + name="systemd", + service_path=UNIT_PATH, + is_loaded=lambda: _sysd_state()[1] == "active", + write=_sysd_write, + start=_sysd_start, + stop=_sysd_stop, + ) + raise click.ClickException( + f"ollama adapter is not supported on {sys.platform} yet." + ) + + +def ensure_running() -> bool: + """Idempotently make sure the adapter is up (used by ``switch provider``).""" + if health_ok(): + return True + backend = _backend() + backend.write() + backend.start() + for _ in range(20): # up to ~10s + if health_ok(): + return True + time.sleep(0.5) + return False + + +@click.group("ollama", invoke_without_command=True) +@click.pass_context +def ollama_cmd(ctx: click.Context) -> None: + """Manage the local Ollama adapter (Claude Code <-> Ollama bridge).""" + if ctx.invoked_subcommand is None: + console.print("[bold]Use one of:[/bold] up, down, status") + + +@ollama_cmd.command("up") +def ollama_up() -> None: + """Install and start the platform service, waiting for health.""" + if health_ok(): + console.print("[green]ollama-adapter already running.[/green]") + return + backend = _backend() + backend.write() + backend.start() + if ensure_running(): + console.print( + f"[bold green]ollama-adapter up[/bold green] " + f"(http://127.0.0.1:{DEFAULT_PORT} -> Ollama, {backend.name})" + ) + else: + raise click.ClickException( + "ollama-adapter failed to become healthy; check the service logs " + f"({backend.name})" + ) + + +@ollama_cmd.command("down") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +def ollama_down(yes: bool) -> None: + """Stop and remove the platform service.""" + backend = _backend() + installed = backend.is_loaded() or backend.service_path.exists() + if not installed: + console.print("[yellow]ollama-adapter not installed.[/yellow]") + return + if not yes and not click.confirm( + "Stop and remove ollama-adapter?", default=False, err=True + ): + console.print("Cancelled.") + return + backend.stop() + console.print("[green]ollama-adapter stopped and removed.[/green]") + + +@ollama_cmd.command("status") +def ollama_status() -> None: + """Show service and health state.""" + up = health_ok() + endpoint = ( + f"[green]up[/green] http://127.0.0.1:{DEFAULT_PORT}" + if up + else f"[red]down[/red] http://127.0.0.1:{DEFAULT_PORT}" + ) + console.print(f" endpoint : {endpoint}") + try: + backend = _backend() + except click.ClickException as exc: + console.print(f" platform : [dim]{exc}[/dim]") + return + console.print(f" platform : {backend.name}") + + if _platform() == "darwin": + loaded = _mac_loaded() + state = "[green]loaded[/green]" if loaded else "[dim]not loaded[/dim]" + console.print(f" launchd agent : {state}") + elif _platform() == "linux": + enabled, active = _sysd_state() + console.print(f" systemd unit : enabled={enabled} active={active}") + + if not up: + err_log = _LOG_DIR / "ollama-adapter.err.log" + if err_log.exists(): + tail = err_log.read_text(encoding="utf-8", errors="replace").strip() + if tail: + for line in tail.splitlines()[-5:]: + console.print(f" [dim]{line}[/dim]") diff --git a/myagents/ollama_adapter.py b/myagents/ollama_adapter.py new file mode 100644 index 0000000..a7bae2b --- /dev/null +++ b/myagents/ollama_adapter.py @@ -0,0 +1,178 @@ +"""Anthropic -> Ollama normalization adapter for Claude Code. + +Claude Code injects some system content (e.g. the agent-types list) as +``role: "system"`` messages inside ``messages[]``, mid-conversation. Qwen-family +chat templates require a leading system message, so Ollama's ``/v1/messages`` +responds HTTP 500 ("System message must be at the beginning"). This adapter +moves any in-messages system content into the top-level Anthropic ``system`` +field before forwarding to Ollama. + +Runs standalone: + + python -m myagents.ollama_adapter # 127.0.0.1:8199 + +Stdlib only (no fastapi/uvicorn) to keep myagents a lightweight CLI package. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +from http.client import HTTPConnection +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +DEFAULT_HOST = os.environ.get("XIAOHE_OLLAMA_ADAPTER_HOST", "127.0.0.1") +DEFAULT_PORT = int(os.environ.get("XIAOHE_OLLAMA_ADAPTER_PORT", "8199")) +# Respect OLLAMA_HOST just like the ollama CLI. +OLLAMA_BASE = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434") + +_log = logging.getLogger("myagents.ollama_adapter") + + +def normalize_system(body: dict) -> dict: + """Move ``role: "system"`` messages into the top-level ``system`` field. + + Mutates and returns *body*. Non-system messages keep their order. + """ + raw_sys = body.get("system") + if isinstance(raw_sys, list): + sys_blocks = [b for b in raw_sys if isinstance(b, dict)] + elif isinstance(raw_sys, str) and raw_sys: + sys_blocks = [{"type": "text", "text": raw_sys}] + else: + sys_blocks = [] + + kept: list = [] + for message in body.get("messages") or []: + if isinstance(message, dict) and message.get("role") == "system": + content = message.get("content") + if isinstance(content, list): + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "text" + and block.get("text") + ): + sys_blocks.append( + {"type": "text", "text": block["text"]} + ) + else: + kept.append(message) + + body["system"] = sys_blocks + body["messages"] = kept + return body + + +class _Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + # -- plumbing ----------------------------------------------------------- + + def _json(self, status: int, data: dict) -> None: + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _forward(self, raw: bytes, path: str) -> None: + """POST to Ollama and stream the response through unchanged.""" + parts = urlsplit(OLLAMA_BASE) + headers = { + "x-api-key": "ollama", + "content-type": "application/json", + "anthropic-version": self.headers.get( + "anthropic-version", "2023-06-01" + ), + "content-length": str(len(raw)), + } + conn = HTTPConnection( + parts.hostname or "127.0.0.1", parts.port or 80, timeout=600 + ) + try: + conn.request("POST", path, body=raw, headers=headers) + resp = conn.getresponse() + except OSError as exc: + _log.warning("upstream %s failed: %s", OLLAMA_BASE, exc) + self._json(502, {"error": {"message": f"upstream error: {exc}"}}) + return + self.send_response(resp.status) + content_type = resp.getheader("content-type") + if content_type: + self.send_header("content-type", content_type) + self.send_header("cache-control", "no-cache") + self.end_headers() + while True: + chunk = resp.read(65536) + if not chunk: + break + try: + self.wfile.write(chunk) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + break + conn.close() + + # -- routes ------------------------------------------------------------- + + def do_GET(self) -> None: + if self.path == "/health": + self._json(200, {"status": "ok", "upstream": OLLAMA_BASE}) + return + if self.path == "/v1/models": + # Anthropic-style model list so /model switching works offline. + try: + from urllib.request import urlopen + + with urlopen(OLLAMA_BASE + "/api/tags", timeout=5) as resp: + data = json.loads(resp.read().decode()) + names = [m["name"] for m in data.get("models", [])] + except OSError: + names = [] + self._json( + 200, + {"data": [{"id": n, "object": "model"} for n in names]}, + ) + return + self._json(404, {"error": {"message": "not found"}}) + + def do_POST(self) -> None: + if self.path.split("?", 1)[0] != "/v1/messages": + self._json(404, {"error": {"message": "not found"}}) + return + length = int(self.headers.get("content-length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw) + except json.JSONDecodeError: + body = None + if isinstance(body, dict): + normalize_system(body) + raw = json.dumps(body).encode() + self._forward(raw, self.path) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + if args: + format = format % args + _log.info("%s %s", self.command, format) + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + server = ThreadingHTTPServer((DEFAULT_HOST, DEFAULT_PORT), _Handler) + server.daemon_threads = True + print( + f"ollama-adapter: http://{DEFAULT_HOST}:{DEFAULT_PORT} -> {OLLAMA_BASE}", + flush=True, + ) + with contextlib.suppress(KeyboardInterrupt): + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/test_ollama.py b/tests/test_ollama.py new file mode 100644 index 0000000..3546a09 --- /dev/null +++ b/tests/test_ollama.py @@ -0,0 +1,119 @@ +"""Tests for myagents.commands.ollama and myagents.ollama_adapter.""" + +import subprocess + +import click +import pytest + +from myagents.commands import ollama +from myagents.ollama_adapter import normalize_system + + +class TestNormalizeSystem: + def test_moves_system_message_to_top_level(self) -> None: + body = { + "system": [{"type": "text", "text": "SYS1"}], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + { + "role": "system", + "content": [{"type": "text", "text": "SYS2"}], + }, + ], + } + out = normalize_system(body) + assert [m["role"] for m in out["messages"]] == ["user"] + assert [b["text"] for b in out["system"]] == ["SYS1", "SYS2"] + + def test_keeps_non_system_order(self) -> None: + body = { + "messages": [ + {"role": "user", "content": "a"}, + { + "role": "system", + "content": [{"type": "text", "text": "s"}], + }, + {"role": "user", "content": "b"}, + ] + } + out = normalize_system(body) + assert [m["role"] for m in out["messages"]] == ["user", "user"] + assert [m["content"] for m in out["messages"]] == ["a", "b"] + + def test_string_system_untouched(self) -> None: + body = {"system": "top", "messages": [{"role": "user", "content": "a"}]} + out = normalize_system(body) + assert out["system"] == [{"type": "text", "text": "top"}] + + +class TestSystemdUnit: + def test_unit_text_smoke(self) -> None: + text = ollama._sysd_unit_text() + assert "ExecStart=" in text + assert "-m myagents.ollama_adapter" in text + assert "WantedBy=default.target" in text + + def test_sysd_start_enables_and_reloads( + self, monkeypatch, tmp_path + ) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + kwargs.pop("capture_output", None) + kwargs.pop("text", None) + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(ollama.subprocess, "run", fake_run) + monkeypatch.setattr(ollama, "UNIT_DIR", tmp_path) + monkeypatch.setattr(ollama, "UNIT_PATH", tmp_path / ollama.UNIT_NAME) + + ollama._sysd_start() + + assert ["systemctl", "--user", "daemon-reload"] in calls + assert [ + "systemctl", + "--user", + "enable", + "--now", + ollama.UNIT_NAME, + ] in calls + assert any(c[0] == "loginctl" and "enable-linger" in c for c in calls) + assert (tmp_path / ollama.UNIT_NAME).exists() + + def test_sysd_stop_disables(self, monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + kwargs.pop("capture_output", None) + kwargs.pop("text", None) + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr(ollama.subprocess, "run", fake_run) + ollama._sysd_stop() + + assert [ + "systemctl", + "--user", + "disable", + "--now", + ollama.UNIT_NAME, + ] in calls + assert ["systemctl", "--user", "daemon-reload"] in calls + + +class TestBackendDispatch: + def test_darwin_launchd(self) -> None: + # Test runs on macOS; _platform() returns the real platform. + if ollama._platform() == "darwin": + assert ollama._backend().name == "launchd" + + def test_linux_systemd(self, monkeypatch) -> None: + monkeypatch.setattr(ollama, "_platform", lambda: "linux") + assert ollama._backend().name == "systemd" + + def test_unsupported_platform_raises(self, monkeypatch) -> None: + monkeypatch.setattr(ollama, "_platform", lambda: "win32") + with pytest.raises(click.ClickException): + ollama._backend()