from __future__ import annotations import importlib.util import json import threading import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import pytest from codexpi.database import Database from codexpi.local_ai_bridge_service import LocalAIBridgeError, LocalAIBridgeService MODELS = [ { "name": "qwen2.5-coder:14b", "size": 8_988_000_000, "details": {"parameter_size": "14.8B", "quantization_level": "Q4_K_M"}, "capabilities": ["completion", "tools"], }, { "name": "gemma3:12b", "size": 8_149_000_000, "details": {"parameter_size": "12.2B", "quantization_level": "Q4_K_M"}, "capabilities": ["completion", "vision"], }, ] def load_bridge_script(): path = Path(__file__).parents[1] / "scripts" / "codexpi_ai_bridge.py" spec = importlib.util.spec_from_file_location("codexpi_ai_bridge_test", path) assert spec and spec.loader module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class FakeOllamaHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.0" def log_message(self, *_args) -> None: return def _send(self, value: dict, status: int = 200) -> None: body = json.dumps(value).encode("utf-8") 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 do_GET(self) -> None: if self.path in {"/api/tags", "/v1/models"}: self._send({"models": MODELS}) elif self.path == "/api/experimental/model-recommendations": self._send({"recommendations": [{ "model": "qwen3:8b", "description": "Local reasoning and tools", "vram_bytes": 6_000_000_000, "thinking": {"values": [False, True], "default": True}, }]}) else: self._send({"error": "missing"}, 404) def do_POST(self) -> None: length = int(self.headers.get("Content-Length") or 0) payload = json.loads(self.rfile.read(length) or b"{}") if self.path == "/v1/chat/completions": self._send({"choices": [{"message": {"role": "assistant", "content": payload.get("model")}}]}) elif self.path == "/api/show": self._send({"capabilities": ["completion", "tools"]}) elif self.path == "/api/pull": rows = [ {"status": "pulling manifest"}, {"status": "pulling layer", "digest": "sha256:one", "total": 100, "completed": 35}, {"status": "pulling layer", "digest": "sha256:one", "total": 100, "completed": 100}, {"status": "verifying sha256 digest"}, {"status": "success"}, ] body = b"".join(json.dumps(item).encode("utf-8") + b"\n" for item in rows) self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) else: self._send({"error": "missing"}, 404) @pytest.fixture def fake_ollama(): server = ThreadingHTTPServer(("127.0.0.1", 0), FakeOllamaHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: yield server finally: server.shutdown() server.server_close() thread.join(timeout=2) def test_local_ai_pairing_encrypts_token_and_writes_isolated_opencode_config(tmp_path, fake_ollama): db = Database(tmp_path / "codexpi.db") db.initialize() service = LocalAIBridgeService(db, "test-secret", tmp_path / "data") pairing = service.create_pairing("owner") token = "a" * 48 bridge = service.pair( pairing["pairing_id"], pairing["code"], "PC bureau", "Windows", "127.0.0.1", fake_ollama.server_address[1], token, ) assert bridge["connected"] is True assert bridge["default_model"] == "qwen2.5-coder:14b" assert bridge["models"][0]["capabilities"] == ["completion", "tools"] assert "token" not in bridge and "token_encrypted" not in bridge stored = db.one("SELECT token_encrypted FROM local_ai_bridges WHERE id=?", (bridge["id"],)) assert stored and stored["token_encrypted"] != token assert service.token_path.read_text(encoding="utf-8") == token config = json.loads(service.config_path.read_text(encoding="utf-8")) assert config["model"] == "ollama/qwen2.5-coder:14b" assert config["providers"]["ollama"]["settings"]["baseURL"].endswith("/v1") assert token not in service.config_path.read_text(encoding="utf-8") with pytest.raises(LocalAIBridgeError, match="déjà utilisé"): service.pair( pairing["pairing_id"], pairing["code"], "PC bureau", "Windows", "127.0.0.1", fake_ollama.server_address[1], token, ) changed = service.configure(bridge["id"], "gemma3:12b") assert changed["default_model"] == "gemma3:12b" assert json.loads(service.config_path.read_text(encoding="utf-8"))["model"] == "ollama/gemma3:12b" service.revoke(bridge["id"]) assert not service.config_path.exists() and not service.token_path.exists() def test_local_ai_service_rejects_public_addresses(tmp_path): db = Database(tmp_path / "codexpi.db") db.initialize() service = LocalAIBridgeService(db, "test-secret", tmp_path / "data") pairing = service.create_pairing("owner") with pytest.raises(LocalAIBridgeError, match="réseau local"): service.pair(pairing["pairing_id"], pairing["code"], "PC", "Windows", "8.8.8.8", 11435, "x" * 48) def test_invalid_pairing_code_never_triggers_network_probe(tmp_path, monkeypatch): db = Database(tmp_path / "codexpi.db") db.initialize() service = LocalAIBridgeService(db, "test-secret", tmp_path / "data") pairing = service.create_pairing("owner") probed = False def fail_if_probed(*_args, **_kwargs): nonlocal probed probed = True raise AssertionError("the bridge must not be contacted") monkeypatch.setattr(service, "probe", fail_if_probed) with pytest.raises(LocalAIBridgeError, match="Code d’association invalide"): service.pair( pairing["pairing_id"], "wrong-pairing-code", "PC", "Windows", "127.0.0.1", 11435, "x" * 48, ) assert probed is False def test_pc_bridge_requires_bearer_and_only_proxies_ollama_routes(fake_ollama): bridge = load_bridge_script() upstream = f"http://127.0.0.1:{fake_ollama.server_address[1]}" server = bridge.create_server("127.0.0.1", 0, upstream, "private-token") thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() base = f"http://127.0.0.1:{server.server_address[1]}" try: with pytest.raises(urllib.error.HTTPError) as unauthorized: urllib.request.urlopen(base + "/api/tags", timeout=2) assert unauthorized.value.code == 401 request = urllib.request.Request(base + "/api/tags", headers={"Authorization": "Bearer private-token"}) with urllib.request.urlopen(request, timeout=2) as response: assert json.loads(response.read())["models"][0]["name"] == "qwen2.5-coder:14b" forbidden = urllib.request.Request(base + "/api/delete", headers={"Authorization": "Bearer private-token"}) with pytest.raises(urllib.error.HTTPError) as missing: urllib.request.urlopen(forbidden, timeout=2) assert missing.value.code == 404 chat = urllib.request.Request( base + "/v1/chat/completions", data=json.dumps({"model": "qwen2.5-coder:14b", "messages": []}).encode("utf-8"), method="POST", headers={"Authorization": "Bearer private-token", "Content-Type": "application/json"}, ) with urllib.request.urlopen(chat, timeout=2) as response: assert json.loads(response.read())["choices"][0]["message"]["content"] == "qwen2.5-coder:14b" pull = urllib.request.Request( base + "/api/pull", data=json.dumps({"model": "qwen3:8b", "stream": True}).encode("utf-8"), method="POST", headers={"Authorization": "Bearer private-token", "Content-Type": "application/json"}, ) with urllib.request.urlopen(pull, timeout=2) as response: events = [json.loads(line) for line in response if line.strip()] assert events[-1]["status"] == "success" finally: server.shutdown() server.server_close() thread.join(timeout=2) def test_local_ai_settings_flow_exposes_models_without_exposing_token(logged_in, monkeypatch): client, app, _, _, headers = logged_in monkeypatch.setattr(app.state.local_ai, "probe", lambda *_args, **_kwargs: app.state.local_ai._models({"models": MODELS})) created = client.post("/api/local-ai/pairings", headers=headers) assert created.status_code == 201 pairing = created.json() paired = client.post("/api/local-ai/agent/pair", json={ "pairing_id": pairing["pairing_id"], "code": pairing["code"], "name": "RTX 3060", "platform": "Windows", "address": "127.0.0.1", "port": 11435, "token": "z" * 48, }) assert paired.status_code == 201 bridge = paired.json() assert bridge["default_model"] == "qwen2.5-coder:14b" assert "token" not in json.dumps(bridge) listed = client.get("/api/local-ai/bridges") assert listed.status_code == 200 and listed.json()[0]["name"] == "RTX 3060" options = client.get("/api/codex/options").json() assert options["local_default_model"] == "ollama/qwen2.5-coder:14b" assert options["local_models"][0]["id"] == "ollama/qwen2.5-coder:14b" assert next(item for item in options["engines"] if item["id"] == "opencode")["label"] == "OpenCode · PC local" assert client.get("/api/local-ai/connector").status_code == 200 assert client.get("/api/local-ai/guide").status_code == 200 catalog = client.get(f"/api/local-ai/bridges/{bridge['id']}/catalog") assert catalog.status_code == 200 assert any(item["name"] == "qwen2.5-coder:14b" and item["installed"] for item in catalog.json()["models"]) updated = client.put( f"/api/local-ai/bridges/{bridge['id']}", headers=headers, json={"default_model": "gemma3:12b", "active": True}, ) assert updated.status_code == 200 and updated.json()["default_model"] == "gemma3:12b" revoked = client.delete(f"/api/local-ai/bridges/{bridge['id']}", headers=headers) assert revoked.status_code == 200 assert client.get("/api/local-ai/bridges").json() == [] def test_local_ai_pull_tracks_real_stream_progress(tmp_path, fake_ollama): db = Database(tmp_path / "codexpi.db") db.initialize() service = LocalAIBridgeService(db, "test-secret", tmp_path / "data") pairing = service.create_pairing("owner") bridge = service.pair( pairing["pairing_id"], pairing["code"], "PC bureau", "Windows", "127.0.0.1", fake_ollama.server_address[1], "p" * 48, ) catalog = service.catalog(bridge["id"]) recommended = next(item for item in catalog["models"] if item["name"] == "qwen3:8b") assert recommended["recommended"] is True assert recommended["fit"] in {"ideal", "possible", "heavy"} job = service.start_pull(bridge["id"], "qwen3:8b") deadline = time.monotonic() + 3 while time.monotonic() < deadline: current = service.list_pulls(bridge["id"])[0] if current["status"] not in {"queued", "downloading", "verifying"}: break time.sleep(0.02) assert current["status"] == "completed" assert current["progress"] == 100.0 assert current["total"] == 100 assert json.loads(service.pulls_path.read_text(encoding="utf-8"))[0]["status"] == "completed"