Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ requests>=2.31.0

# YAML parsing (role_routing.yaml)
pyyaml>=6.0

# Optional Metis local-first lookup (workers/metis_local_first.py). Fails
# soft to cloud providers if unreachable or not installed.
mcp>=1.28.0
64 changes: 61 additions & 3 deletions scripts/solve_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -1853,6 +1853,38 @@ def get_worker_adapter(model: str):
raise ValueError(f"Unbekannter Worker-Provider: '{model}'")


def resolve_local_first_dispatch(
local_first: bool,
model: str,
model_name: str,
cfg: dict,
user: str,
solver_config: dict,
) -> tuple[str, str, dict]:
"""Try a live Metis cluster Ollama worker before falling back to `model`.

Solver-role only (see role_routing.yaml / ecosystem-coordination decision):
the caller is responsible for only setting `local_first=True` outside
ensemble/dry-run runs. Fail-soft — find_local_solver_candidate() never
raises, so any Metis/candidate unavailability just returns the unchanged
(model, model_name, solver_config) the caller already had.
"""
if not local_first:
return model, model_name, solver_config

from workers.metis_local_first import build_ollama_run_config, find_local_solver_candidate

candidate = find_local_solver_candidate()
if candidate is None:
return model, model_name, solver_config

local_model, local_model_name, ollama_config = build_ollama_run_config(candidate)
local_config = {"owner": user, "config": {**cfg, **ollama_config}}
worker_name = candidate.get("worker", {}).get("id", "?")
print(f" 🏠 Metis local-first: {local_model_name} auf {worker_name}")
return local_model, local_model_name, local_config


def build_worker_command(
model: str,
model_name: str,
Expand Down Expand Up @@ -4063,6 +4095,15 @@ def main():
action="store_true",
help="Modell automatisch basierend auf Issue-Typ, Risiko und Kosten auswählen"
)
parser.add_argument(
"--local-first",
action="store_true",
help=(
"Vor --model erst einen lebenden, lokalen Ollama-Worker im Metis-Cluster "
"anfragen (nur solver-Rolle); ohne Treffer wird --model wie gewohnt genutzt. "
"Erfordert aider (der lokale Pfad läuft ueber den ollama-Aider-Provider)."
),
)
parser.add_argument(
"--max-cost",
choices=["cheap", "medium", "expensive"],
Expand Down Expand Up @@ -4356,6 +4397,14 @@ def main():
print(" → Mehr Infos: docs/SETUP_AIDER.md")
sys.exit(1)

if args.local_first and args.model in ("codex", "mistral-vibe", "opencode") and not check_aider_installed() and not args.dry_run:
# --local-first kann args.model fuer diesen Lauf durch den ollama-Aider-Provider
# ersetzen, auch wenn das urspruenglich gewaehlte Modell kein aider-Provider ist.
print_err("aider ist nicht installiert, wird aber fuer --local-first benoetigt!")
print(" → Installieren mit: pip install aider-chat")
print(" → Mehr Infos: docs/SETUP_AIDER.md")
sys.exit(1)

# Modellauswahl
if args.auto_model:
from model_selection import select_model_for_issue
Expand Down Expand Up @@ -4566,13 +4615,22 @@ def main():
if budget_msg:
print_warn(budget_msg)

issue_model, issue_model_name, issue_config = resolve_local_first_dispatch(
local_first=args.local_first and not args.dry_run and args.ensemble == 0,
model=args.model,
model_name=model_name,
cfg=cfg,
user=user,
solver_config=solver_config,
)

ok = solve_issue(
client=client,
issue=issue,
repo=repo_name,
model=args.model,
model_name=model_name,
config=solver_config,
model=issue_model,
model_name=issue_model_name,
config=issue_config,
token=token,
dry_run=args.dry_run,
base_branch=base_branch,
Expand Down
110 changes: 110 additions & 0 deletions tests/test_metis_local_first.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
Tests für die optionale Metis-Local-First-Anbindung (workers/metis_local_first.py).

Abgedeckte Szenarien:
- Erreichbarer Fake-Metis-MCP-Server liefert einen Ollama-Kandidaten zurück.
- Kein Kandidat (ollama_reachable=False) -> None, kein Fehler.
- Unerreichbarer Metis-Endpunkt (falscher Port) -> None, kein Fehler.
- build_ollama_run_config() erzeugt die exakte Form, die
AiderAdapter(provider="ollama") bereits erwartet (config["OLLAMA_HOST"],
model_name als Ollama-Tag).

Startet einen echten lokalen MCP-Server (FastMCP, Streamable HTTP) als
Test-Double, damit der komplette Client-Pfad (Session-Init, Tool-Call,
JSON-Parsing) real ausgeführt wird statt gemockt.
"""

from __future__ import annotations

import socket
import sys
import threading
import time
import unittest
from contextlib import closing
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

from workers.metis_local_first import build_ollama_run_config, find_local_solver_candidate


def _free_port() -> int:
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]


def _run_fake_metis_server(port: int, candidates: list[dict], ready: threading.Event) -> None:
from mcp.server.fastmcp import FastMCP

app = FastMCP("fake-metis", host="127.0.0.1", port=port, stateless_http=True)

@app.tool()
def find_execution_options(capabilities: list[str], min_memory_gb: float | None = None) -> dict:
return {"capabilities_requested": capabilities, "candidates": candidates}

ready.set()
app.run(transport="streamable-http")


class MetisLocalFirstTests(unittest.TestCase):
def _start_fake_server(self, candidates: list[dict]) -> str:
port = _free_port()
ready = threading.Event()
thread = threading.Thread(
target=_run_fake_metis_server, args=(port, candidates, ready), daemon=True
)
thread.start()
ready.wait(timeout=5)
time.sleep(0.3) # uvicorn needs a moment to actually bind after app.run() starts
self.addCleanup(lambda: None) # daemon thread dies with the test process
return f"http://127.0.0.1:{port}/mcp"

def test_finds_reachable_ollama_candidate(self):
candidates = [
{
"worker": {"id": "mac-imac-2", "ollama": {"base_url": "http://192.168.178.43:11434"}},
"ollama_reachable": True,
"ollama_live_models": ["qwen2.5-coder:3b"],
}
]
url = self._start_fake_server(candidates)

candidate = find_local_solver_candidate(capabilities=["local-llm"], metis_url=url)

self.assertIsNotNone(candidate)
self.assertEqual(candidate["worker"]["id"], "mac-imac-2")

model, model_name, config = build_ollama_run_config(candidate)
self.assertEqual(model, "ollama")
self.assertEqual(model_name, "qwen2.5-coder:3b")
self.assertEqual(config, {"OLLAMA_HOST": "http://192.168.178.43:11434"})

def test_no_reachable_candidate_returns_none(self):
candidates = [
{
"worker": {"id": "gaming-pc", "ollama": None},
"ollama_reachable": False,
"ollama_live_models": [],
}
]
url = self._start_fake_server(candidates)

candidate = find_local_solver_candidate(capabilities=["local-llm"], metis_url=url)

self.assertIsNone(candidate)

def test_unreachable_metis_returns_none_without_raising(self):
# Nothing listening on this port.
port = _free_port()
candidate = find_local_solver_candidate(
capabilities=["local-llm"], metis_url=f"http://127.0.0.1:{port}/mcp"
)
self.assertIsNone(candidate)


if __name__ == "__main__":
unittest.main()
74 changes: 74 additions & 0 deletions tests/test_solve_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
should_surface_worker_line,
sleep_until_codex_reset,
main as solve_issues_main,
resolve_local_first_dispatch,
validate_worker_changes,
solve_issue,
write_run_report,
Expand Down Expand Up @@ -3187,5 +3188,78 @@ def test_multiple_flags_combined(self):
)


class TestResolveLocalFirstDispatch(unittest.TestCase):
"""solve_issues.resolve_local_first_dispatch — the per-issue Metis local-first
override wired into main()'s dispatch (ai-issue-solver PR #489 follow-up)."""

def test_disabled_returns_original_model_unchanged(self):
solver_config = {"owner": "u", "config": {"OPENROUTER_API_KEY": "x"}}
with patch("workers.metis_local_first.find_local_solver_candidate") as mock_find:
model, model_name, config = resolve_local_first_dispatch(
local_first=False,
model="openrouter",
model_name="openrouter/openai/gpt-4o-mini",
cfg={"OPENROUTER_API_KEY": "x"},
user="u",
solver_config=solver_config,
)
mock_find.assert_not_called()
self.assertEqual(model, "openrouter")
self.assertEqual(model_name, "openrouter/openai/gpt-4o-mini")
self.assertIs(config, solver_config)

def test_no_candidate_falls_back_to_original_model(self):
solver_config = {"owner": "u", "config": {"OPENROUTER_API_KEY": "x"}}
with patch(
"workers.metis_local_first.find_local_solver_candidate", return_value=None
):
model, model_name, config = resolve_local_first_dispatch(
local_first=True,
model="openrouter",
model_name="openrouter/openai/gpt-4o-mini",
cfg={"OPENROUTER_API_KEY": "x"},
user="u",
solver_config=solver_config,
)
self.assertEqual(model, "openrouter")
self.assertEqual(model_name, "openrouter/openai/gpt-4o-mini")
self.assertIs(config, solver_config)

def test_live_candidate_overrides_model_and_injects_ollama_host(self):
solver_config = {"owner": "u", "config": {"OPENROUTER_API_KEY": "x"}}
fake_candidate = {
"worker": {"id": "mac-imac-2", "ollama": {"base_url": "http://192.168.178.43:11434"}},
"ollama_reachable": True,
"ollama_live_models": ["qwen2.5-coder:3b"],
}
with patch(
"workers.metis_local_first.find_local_solver_candidate",
return_value=fake_candidate,
):
model, model_name, config = resolve_local_first_dispatch(
local_first=True,
model="openrouter",
model_name="openrouter/openai/gpt-4o-mini",
cfg={"OPENROUTER_API_KEY": "x"},
user="u",
solver_config=solver_config,
)
self.assertEqual(model, "ollama")
self.assertEqual(model_name, "qwen2.5-coder:3b")
# Original cloud key preserved alongside the injected OLLAMA_HOST, and
# the caller's solver_config dict must not be mutated in place.
self.assertEqual(
config,
{
"owner": "u",
"config": {
"OPENROUTER_API_KEY": "x",
"OLLAMA_HOST": "http://192.168.178.43:11434",
},
},
)
self.assertEqual(solver_config["config"], {"OPENROUTER_API_KEY": "x"})


if __name__ == "__main__":
unittest.main()
89 changes: 89 additions & 0 deletions workers/metis_local_first.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Optional local-first lookup against the Metis cluster MCP server.

Metis (github.com/SaJaToGu/metis) tracks the home-lab worker cluster and
exposes a read-only MCP server describing which workers currently have a
live, reachable Ollama endpoint. This module is a thin, fail-soft client:
if Metis is unreachable or has no matching candidate, callers fall back to
their normally configured (cloud) provider — nothing here should ever raise
for an unreachable Metis instance, since it is an optional optimization, not
a hard dependency for solving an issue.

Usage (aider's existing "ollama" provider already supports this shape, see
workers/aider_adapter.py's AIDER_MODEL_CONFIGS["ollama"] and _build_aider_env):

candidate = find_local_solver_candidate()
if candidate is not None:
model, model_name, extra_config = build_ollama_run_config(candidate)
# model == "ollama", model_name == e.g. "qwen2.5-coder:3b"
# extra_config == {"OLLAMA_HOST": "http://192.168.178.43:11434"}
"""

from __future__ import annotations

import asyncio
import os
from typing import Any

DEFAULT_METIS_MCP_URL = os.environ.get("METIS_MCP_URL", "http://192.168.178.40:8770/mcp")

# Capabilities the "solver" role asks Metis for by default. Kept narrow and
# specific on purpose: this is only used for the low-risk, high-volume
# solver role (see the ecosystem-coordination decision in the Metis repo),
# not for planner/architecture work where cloud model quality matters more.
DEFAULT_SOLVER_CAPABILITIES = ["local-llm"]


async def _find_candidate_async(capabilities: list[str], metis_url: str) -> dict[str, Any] | None:
try:
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
except ImportError:
return None

try:
async with asyncio.timeout(5):
async with streamablehttp_client(metis_url) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(
"find_execution_options", {"capabilities": capabilities}
)
except Exception:
# Metis unreachable, MCP handshake failed, timeout, etc. - fall back silently.
return None

import json

try:
data = json.loads(result.content[0].text)
except (IndexError, AttributeError, ValueError):
return None

for candidate in data.get("candidates", []):
if candidate.get("ollama_reachable") and candidate.get("ollama_live_models"):
return candidate
return None


def find_local_solver_candidate(
capabilities: list[str] | None = None,
metis_url: str = DEFAULT_METIS_MCP_URL,
) -> dict[str, Any] | None:
"""Return the first live, reachable Ollama candidate for these capabilities.

Returns None (never raises) if Metis is unreachable, has no candidate,
or the `mcp` package is not installed - all fail-soft fallback cases.
"""
caps = capabilities if capabilities is not None else DEFAULT_SOLVER_CAPABILITIES
return asyncio.run(_find_candidate_async(caps, metis_url))


def build_ollama_run_config(candidate: dict[str, Any]) -> tuple[str, str, dict[str, str]]:
"""Turn a Metis candidate into (model, model_name, config) for get_worker_adapter/AiderAdapter.

model is always "ollama" - this reuses the existing AIDER_MODEL_CONFIGS["ollama"]
provider as-is, so no new adapter class is needed.
"""
ollama = candidate["worker"]["ollama"]
model_name = candidate["ollama_live_models"][0]
return "ollama", model_name, {"OLLAMA_HOST": ollama["base_url"]}
Loading