feat(ollama): Anthropic→Ollama 归一化 adapter + launchd/systemd 常驻管理

This commit is contained in:
Zhengshou Lai
2026-08-15 23:31:39 +08:00
parent 75a72a8b24
commit 0b0de97363
4 changed files with 625 additions and 0 deletions
+2
View File
@@ -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))
+326
View File
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{sys.executable}</string>
<string>-m</string>
<string>myagents.ollama_adapter</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>{Path.home()}</string>
<key>StandardOutPath</key>
<string>{_LOG_DIR / "ollama-adapter.out.log"}</string>
<key>StandardErrorPath</key>
<string>{_LOG_DIR / "ollama-adapter.err.log"}</string>
</dict>
</plist>
"""
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]")
+178
View File
@@ -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()
+119
View File
@@ -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()