diff --git a/Makefile b/Makefile index 10e80bd..f20b29e 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,9 @@ validate-consent-before-staging: validate-inference-gateway: $(PYTHON) scripts/validate-inference-gateway.py +validate-inference-backends: + $(PYTHON) scripts/validate-inference-backends.py + validate-inference-receipt: $(PYTHON) scripts/validate-inference-receipt.py diff --git a/scripts/validate-inference-backends.py b/scripts/validate-inference-backends.py new file mode 100644 index 0000000..398c868 --- /dev/null +++ b/scripts/validate-inference-backends.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Validate the Ollama serving backend: admitted → real output; failure → audited denial. + +Uses an injected transport so no live daemon is required. The property asserted is that +a backend failure (daemon down, malformed response) never becomes a silent success — the +gateway turns it into a denied audit with no output. One positive case confirms a served +call returns the model output with usage. +""" +from __future__ import annotations +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parents[1] / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from agent_machine.inference_gateway import serve # noqa: E402 +from agent_machine.inference_backends import ollama_backend # noqa: E402 + +ACTIVE = {"id": "local-ollama", "kind": "InferenceProvider", "status": "active"} +REQ = {"model": "llama3.3", "caller": "human:michael", "purpose": "discover", + "space": "user-space", "input": "say hi", + "consent": {"policyDecisionRef": "urn:srcos:policy-decision:abc"}} + +failures = [] +def check(name, cond): + print((" ok " if cond else " FAIL ") + name) + if not cond: failures.append(name) + +# ── positive: injected transport returns a canned Ollama reply ── +seen = {} +def fake_ok(url, payload): + seen["url"] = url; seen["payload"] = payload + return {"response": "hi there", "prompt_eval_count": 3, "eval_count": 2} +resp, audit = serve(REQ, provider=ACTIVE, backend=ollama_backend(transport=fake_ok)) +check("served call returns model output", resp and resp["output"] == "hi there") +check("usage mapped from ollama counts", resp and resp["usage"] == {"prompt_tokens": 3, "completion_tokens": 2}) +check("audit outcome ok", audit["outcome"] == "ok") +check("request hits /api/generate with model+prompt", seen.get("url","").endswith("/api/generate") + and seen["payload"]["model"] == "llama3.3" and "say hi" in seen["payload"]["prompt"]) + +# ── negative: daemon unreachable → transport raises → audited denial, no output ── +def down(url, payload): raise ConnectionError("daemon down") +resp, audit = serve(REQ, provider=ACTIVE, backend=ollama_backend(transport=down)) +check("unreachable daemon → no output", resp is None) +check("unreachable daemon → denied audit", audit["outcome"] == "denied") + +# ── negative: malformed response (no 'response' field) → audited denial ── +def bad(url, payload): return {"unexpected": True} +resp, audit = serve(REQ, provider=ACTIVE, backend=ollama_backend(transport=bad)) +check("malformed reply → no output", resp is None) +check("malformed reply → denied audit", audit["outcome"] == "denied") + +# ── still fail-closed on missing consent, even with a working backend ── +resp, audit = serve({**REQ, "consent": {}}, provider=ACTIVE, backend=ollama_backend(transport=fake_ok)) +check("no consent still refused with live backend", resp is None and audit["outcome"] == "denied") + +if failures: + print(f"\nFAILED: {len(failures)} check(s)"); sys.exit(1) +print("\nOK: Ollama backend serves when admitted and is an audited denial on any failure") diff --git a/src/agent_machine/inference_backends.py b/src/agent_machine/inference_backends.py new file mode 100644 index 0000000..222c6c4 --- /dev/null +++ b/src/agent_machine/inference_backends.py @@ -0,0 +1,65 @@ +"""Serving backends for the local InferenceGateway adapter. + +A backend is the callable the gateway invokes once a call is admitted: +`backend(request) -> {"output": str, "usage": dict}`. The Ollama backend runs a model +on the local Ollama daemon — no egress, fully sovereign (privacy_profile sovereign-local). +The HTTP transport is injectable so the backend is testable without a live daemon, and a +failure (daemon down, bad response) raises — which the gateway turns into an audited +denial, never a silent success. +""" +from __future__ import annotations + +import json +import os +import urllib.request +from typing import Any, Callable, Dict, List, Optional + +DEFAULT_HOST = "http://127.0.0.1:11434" +Transport = Callable[[str, Dict[str, Any]], Dict[str, Any]] + + +def _urllib_transport(url: str, payload: Dict[str, Any]) -> Dict[str, Any]: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 (local daemon) + return json.loads(resp.read().decode("utf-8")) + + +def _to_prompt(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): # chat-style messages → flattened prompt + parts: List[str] = [] + for m in value: + if isinstance(m, dict): + parts.append(f"{m.get('role', 'user')}: {m.get('content', '')}") + else: + parts.append(str(m)) + return "\n".join(parts) + return str(value) + + +def ollama_backend(*, model: Optional[str] = None, host: Optional[str] = None, + transport: Optional[Transport] = None) -> Callable[[Dict[str, Any]], Dict[str, Any]]: + """Return a gateway backend that serves via the local Ollama daemon (no egress).""" + host = (host or os.environ.get("OLLAMA_HOST") or DEFAULT_HOST).rstrip("/") + xport = transport or _urllib_transport + + def _call(request: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "model": request.get("model") or model or "llama3.3", + "prompt": _to_prompt(request.get("input", "")), + "stream": False, + } + data = xport(f"{host}/api/generate", payload) + if not isinstance(data, dict) or "response" not in data: + raise ValueError("ollama: response missing 'response' field") + return { + "output": data["response"], + "usage": { + "prompt_tokens": data.get("prompt_eval_count", 0), + "completion_tokens": data.get("eval_count", 0), + }, + } + + return _call