179 lines
6.2 KiB
Python
179 lines
6.2 KiB
Python
"""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()
|