feat(ollama): Anthropic→Ollama 归一化 adapter + launchd/systemd 常驻管理
This commit is contained in:
@@ -7,6 +7,7 @@ import click
|
|||||||
from myagents.commands import update_cmd, upgrade_cmd
|
from myagents.commands import update_cmd, upgrade_cmd
|
||||||
from myagents.commands.completion import build_completion_group
|
from myagents.commands.completion import build_completion_group
|
||||||
from myagents.commands.ensure_agent import ensure_agent_cmd
|
from myagents.commands.ensure_agent import ensure_agent_cmd
|
||||||
|
from myagents.commands.ollama import ollama_cmd
|
||||||
from myagents.launcher import build_cli
|
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(ensure_agent_cmd)
|
||||||
cli.add_command(update_cmd)
|
cli.add_command(update_cmd)
|
||||||
cli.add_command(upgrade_cmd, name="upgrade")
|
cli.add_command(upgrade_cmd, name="upgrade")
|
||||||
|
cli.add_command(ollama_cmd)
|
||||||
cli.add_command(build_completion_group(_progs))
|
cli.add_command(build_completion_group(_progs))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user