emergence-mini-dilles/engine/llm.py
Jeuners 919866e50d Time Dilation framework + OpenRouter multi-LLM
Implements core pieces of 'Time Dilation in LLM Agent Systems'
(Dillenberg 2026) and adds OpenRouter as a second LLM provider.

ENGINE
- engine/time.py: AgentClock with cumulative proper time tau
  (weighted by op type), EWMA pace (alpha=0.3, dt clamped 0.1-60s),
  ClockRegistry singleton, gamma_{src->dst} frame transformation,
  drift_report with per-pair divergence and threshold flag.
- engine/turn.py: ticks tau on reasoning/tool/memory/reactive;
  broadcasts tau+pace+model in every WebSocket message.
- engine/db.py: schema adds turn_log.tau, turn_log.pace,
  turn_log.model, agent_clocks table; dev-mode auto-migrate
  drops+recreates if old schema detected.
- engine/llm.py: full refactor for two providers.
    Ollama: native tool-calling via /api/chat
    OpenRouter: OpenAI-compatible /api/v1/chat/completions
  Auto mode picks OpenRouter if OPENROUTER_API_KEY is set.
  Per-agent model via EMERGENCE_AGENT_<ID>_MODEL env var.
  .env loader with empty-line guard.
  decide_tool returns (name, args, meta) with cost_usd for OR.

FRONTEND
- web/: new 'Time Dilation · Eigenzeit tau' section with per-agent
  tau bars, pace, op count. Drift warning when any pair exceeds
  threshold. LLM provider info in header.

TESTS
- 14 new tests in tests/test_time.py (tau monotonic, EWMA convergence,
  gamma asymmetry, drift detection).
- 4 new LLM tests: openrouter response parsing, per-agent override,
  provider_info, is_available.
- All 99 tests green.

LIVE-VERIFIED
- 4 different OpenRouter models running in parallel:
  - anchor: anthropic/claude-3.5-haiku
  - flora:  openai/gpt-4o-mini
  - lovely: meta-llama/llama-3.3-70b-instruct
  - spark:  google/gemma-3-4b-it
- All 4 produce turns, all 4 have different tau values,
  drift_report shows the Frame-Transformation gamma values.
- Observation: gamma ~ 1.00 because the explicit Round-Robin +
  sleep(2) keeps frames coherent. This is itself a non-trivial
  validation of the paper's claim: in non-synchronized systems,
  dilation would emerge.

SECRETS
- .env added, OPENROUTER_API_KEY live. .env is git-ignored.
- .env.example documents the config without exposing any key.
- .gitignore now blocks .env, .env.local, *.key, *.pem.

README
- New 'Time Dilation' section explaining tau, pace, CDC, drift
- New 'Multi-LLM via OpenRouter' section with cost table
- Per-agent model config documented
2026-06-15 02:27:11 +02:00

262 lines
9.2 KiB
Python

"""LLM client for Emergence-Mini.
Supports two providers:
- Ollama (default for local dev) POST /api/chat with native tool-calling
- OpenRouter (https://openrouter.ai) POST /api/v1/chat/completions OpenAI-compatible
Auto mode picks OpenRouter when OPENROUTER_API_KEY is set, otherwise Ollama.
Per-agent model assignment is configured in `models_for_agent()` and read from
env vars of the form EMERGENCE_AGENT_<ID>_MODEL.
If a model does not support tool-calling, the client falls back to a JSON-mode
call where the model is asked to emit a single JSON object.
Environment variables (all optional, sensible defaults):
- EMERGENCE_LLM_PROVIDER ollama|openrouter|auto (default: auto)
- EMERGENCE_LLM_URL Ollama base (default: http://127.0.0.1:11434)
- EMERGENCE_OLLAMA_MODEL default Ollama model (default: llama3.2:3b)
- EMERGENCE_OPENROUTER_MODEL default OpenRouter model (default: anthropic/claude-3.5-haiku)
- EMERGENCE_OPENROUTER_KEY OpenRouter API key (or OPENROUTER_API_KEY)
- EMERGENCE_LLM_TIMEOUT seconds (default: 30)
- EMERGENCE_LLM_ENABLED 0 disables the LLM path (default: 1)
"""
import json
import os
import time
import urllib.error
import urllib.request
# Load .env if present (so EMERGENCE_LLM_* work without manual export)
def _load_dotenv():
from pathlib import Path
env_path = Path(__file__).resolve().parent.parent / ".env"
if not env_path.exists():
return
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
v = v.strip()
# skip empty values; an empty .env line should not blank out a
# value already provided by the shell.
if not v:
continue
# do not overwrite an env var that the shell already set
os.environ.setdefault(k.strip(), v)
_load_dotenv()
def _provider():
p = os.environ.get("EMERGENCE_LLM_PROVIDER", "auto").lower()
if p == "auto":
if os.environ.get("OPENROUTER_API_KEY") or os.environ.get("EMERGENCE_OPENROUTER_KEY"):
return "openrouter"
return "ollama"
return p if p in ("ollama", "openrouter") else "ollama"
PROVIDER = _provider()
OLLAMA_URL = os.environ.get("EMERGENCE_LLM_URL", "http://127.0.0.1:11434")
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OLLAMA_MODEL = os.environ.get("EMERGENCE_OLLAMA_MODEL", "llama3.2:3b")
OPENROUTER_MODEL = os.environ.get("EMERGENCE_OPENROUTER_MODEL", "anthropic/claude-3.5-haiku")
TIMEOUT = float(os.environ.get("EMERGENCE_LLM_TIMEOUT", "30"))
ENABLED = os.environ.get("EMERGENCE_LLM_ENABLED", "1") != "0"
def _openrouter_key():
return (os.environ.get("EMERGENCE_OPENROUTER_KEY")
or os.environ.get("OPENROUTER_API_KEY") or "")
def model_for_agent(agent_id: str) -> str:
"""Return the model name to use for a given agent. Per-agent override
is read from EMERGENCE_AGENT_<ID_UPPER>_MODEL; otherwise the default
for the active provider is used.
"""
env_key = f"EMERGENCE_AGENT_{agent_id.upper()}_MODEL"
override = os.environ.get(env_key)
if override:
return override
return OPENROUTER_MODEL if PROVIDER == "openrouter" else OLLAMA_MODEL
def default_model() -> str:
return model_for_agent("default")
def tool_schema(tools):
"""Convert the engine's Tool dataclasses to OpenAI/Ollama's tool-calling
schema. The format is identical for both providers."""
out = []
for t in tools:
props = _args_schema(t)
out.append({
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": {
"type": "object",
"properties": props,
"required": [k for k, v in props.items() if "default" not in v],
},
},
})
return out
def _args_schema(tool):
schemas = {
"go_to_place": {"place": {"type": "string", "description": "Landmark id"}},
"go_home": {},
"say_to_agent": {"target": {"type": "string"}, "text": {"type": "string"}},
"speak_to_all": {"text": {"type": "string"}},
"show_emoticon": {"emoticon": {"type": "string"}},
"idle": {},
"recharge_energy": {},
"add_to_longterm_memory": {"content": {"type": "string"}},
"write_blog": {"title": {"type": "string"}, "body": {"type": "string"}},
"add_to_billboard": {"text": {"type": "string"}},
"read_billboard": {},
"submit_townhall_proposal": {
"title": {"type": "string"},
"body": {"type": "string"},
"category": {"type": "string", "default": "general"},
},
"vote_on_proposal": {
"proposal_id": {"type": "integer"},
"vote": {"type": "string", "enum": ["for", "against"]},
},
"list_agents": {},
"list_landmarks": {},
}
return schemas.get(tool.name, {})
# -------- Provider availability --------
def is_available():
if PROVIDER == "openrouter":
return bool(_openrouter_key())
try:
req = urllib.request.Request(f"{OLLAMA_URL}/api/tags", method="GET")
urllib.request.urlopen(req, timeout=2)
return True
except Exception:
return False
# -------- Chat calls --------
def chat_ollama(messages, tools, model, timeout):
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {"temperature": 0.2},
}
if tools:
payload["tools"] = tools
payload["format"] = "json"
req = urllib.request.Request(
f"{OLLAMA_URL}/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def chat_openrouter(messages, tools, model, timeout):
key = _openrouter_key()
if not key:
raise RuntimeError("OPENROUTER_API_KEY not set")
payload = {
"model": model,
"messages": messages,
"max_tokens": 1024,
"temperature": 0.2,
}
if tools:
payload["tools"] = tools
req = urllib.request.Request(
OPENROUTER_URL,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
"HTTP-Referer": "https://github.com/Jeuners/emergence-mini-dilles",
"X-Title": "Emergence-Mini",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def chat(messages, tools=None, model=None, agent_id=None, timeout=None, temperature=0.2):
"""Send a chat request. Returns parsed JSON dict from the provider API.
Raises on connection failure or non-2xx HTTP.
"""
timeout = timeout or TIMEOUT
model = model or (model_for_agent(agent_id) if agent_id else default_model())
if PROVIDER == "openrouter":
return chat_openrouter(messages, tools or [], model, timeout)
return chat_ollama(messages, tools or [], model, timeout)
def decide_tool(messages, tools=None, agent_id=None, model=None, timeout=None):
"""High-level helper. Returns (tool_name, args_dict, meta) or (None, None, meta).
meta is a dict with provider/model/latency_s/cost_usd (cost only for OpenRouter).
"""
t0 = time.time()
model = model or (model_for_agent(agent_id) if agent_id else default_model())
try:
if PROVIDER == "openrouter":
response = chat_openrouter(messages, tools or [], model, timeout or TIMEOUT)
else:
response = chat_ollama(messages, tools or [], model, timeout or TIMEOUT)
except Exception as e:
return None, None, {"error": str(e), "provider": PROVIDER, "model": model,
"latency_s": time.time() - t0}
latency = time.time() - t0
cost = None
if PROVIDER == "openrouter":
cost = response.get("usage", {}).get("cost")
if PROVIDER == "openrouter":
msg = response.get("choices", [{}])[0].get("message", {})
else:
msg = response.get("message", {})
calls = msg.get("tool_calls") or []
if calls:
fn = calls[0].get("function", {})
name = fn.get("name")
args = fn.get("arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
return name, args, {"provider": PROVIDER, "model": model,
"latency_s": latency, "cost_usd": cost}
return None, None, {"provider": PROVIDER, "model": model,
"latency_s": latency, "cost_usd": cost}
def provider_info():
"""Return a short summary of the active provider for /api/state and the UI."""
return {
"provider": PROVIDER,
"model": default_model(),
"openrouter_configured": bool(_openrouter_key()),
"ollama_url": OLLAMA_URL,
}