feat: publish CodexPi contest snapshot
Some checks are pending
Qualité / tests (push) Waiting to run

This commit is contained in:
CodexPi
2026-11-14 20:23:59 +01:00
commit 995b17a6e2
295 changed files with 36803 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""Authenticated LAN bridge from CodexPi to a PC-local Ollama server."""
from __future__ import annotations
import argparse
import hmac
import json
import os
import platform
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
MAX_REQUEST_BYTES = 64 * 1024 * 1024
ALLOWED_GET = {"/api/tags", "/v1/models", "/api/experimental/model-recommendations"}
ALLOWED_POST = {"/api/show", "/api/chat", "/api/generate", "/api/pull", "/v1/chat/completions", "/v1/responses"}
def checked_url(value: str, *, local_only: bool = False) -> str:
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Adresse HTTP invalide")
if local_only and parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
raise ValueError("Ollama doit rester attaché à l’adresse locale du PC")
return value.rstrip("/")
def json_request(server: str, path: str, body: dict) -> dict:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
checked_url(server) + path,
data=data,
method="POST",
headers={"Accept": "application/json", "Content-Type": "application/json", "User-Agent": "CodexPi-AI-Bridge/1"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
try:
detail = json.loads(exc.read()).get("detail")
except Exception:
detail = None
raise RuntimeError(detail or f"Erreur CodexPi HTTP {exc.code}") from exc
value = json.loads(raw or b"{}")
if not isinstance(value, dict):
raise RuntimeError("Réponse CodexPi invalide")
return value
def ollama_models(upstream: str) -> list[dict]:
try:
with urllib.request.urlopen(checked_url(upstream, local_only=True) + "/api/tags", timeout=5) as response:
value = json.loads(response.read(2_000_000) or b"{}")
except (OSError, urllib.error.URLError, ValueError) as exc:
raise RuntimeError("Ollama ne répond pas sur ce PC. Ouvrez Ollama puis réessayez.") from exc
models = value.get("models") if isinstance(value, dict) else None
if not isinstance(models, list) or not models:
raise RuntimeError("Ollama fonctionne mais aucun modèle n’est installé")
return models
def hardware_info() -> dict:
"""Return only coarse sizing data needed to recommend a local model."""
memory_bytes = 0
try:
if platform.system() == "Windows":
import ctypes
class MemoryStatus(ctypes.Structure):
_fields_ = [
("length", ctypes.c_ulong),
("memory_load", ctypes.c_ulong),
("total_physical", ctypes.c_ulonglong),
("available_physical", ctypes.c_ulonglong),
("total_page_file", ctypes.c_ulonglong),
("available_page_file", ctypes.c_ulonglong),
("total_virtual", ctypes.c_ulonglong),
("available_virtual", ctypes.c_ulonglong),
("available_extended_virtual", ctypes.c_ulonglong),
]
status = MemoryStatus()
status.length = ctypes.sizeof(status)
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
memory_bytes = int(status.total_physical)
elif hasattr(os, "sysconf"):
memory_bytes = int(os.sysconf("SC_PAGE_SIZE")) * int(os.sysconf("SC_PHYS_PAGES"))
except (AttributeError, OSError, TypeError, ValueError):
memory_bytes = 0
gpu_name = ""
gpu_vram_bytes = 0
executable = shutil.which("nvidia-smi")
if executable:
try:
result = subprocess.run(
[executable, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
check=True,
capture_output=True,
text=True,
timeout=5,
)
candidates: list[tuple[int, str]] = []
for line in result.stdout.splitlines():
name, separator, memory = line.rpartition(",")
if separator:
candidates.append((int(memory.strip()) * 1024 * 1024, name.strip()[:120]))
if candidates:
gpu_vram_bytes, gpu_name = max(candidates)
except (OSError, subprocess.SubprocessError, ValueError):
pass
try:
disk = shutil.disk_usage(Path.home())
disk_free_bytes = int(disk.free)
except OSError:
disk_free_bytes = 0
return {
"platform": platform.system(),
"architecture": platform.machine(),
"memory_bytes": memory_bytes,
"disk_free_bytes": disk_free_bytes,
"gpu_name": gpu_name,
"gpu_vram_bytes": gpu_vram_bytes,
}
def save_config(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix="ai-bridge-", suffix=".json", dir=path.parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.write("\n")
try:
os.chmod(temporary, 0o600)
except OSError:
pass
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def discover_address(server: str) -> str:
parsed = urllib.parse.urlsplit(checked_url(server))
port = parsed.port or (443 if parsed.scheme == "https" else 80)
addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_DGRAM)
last_error: OSError | None = None
for family, socket_type, protocol, _, target in addresses:
probe = socket.socket(family, socket_type, protocol)
try:
probe.connect(target)
return str(probe.getsockname()[0])
except OSError as exc:
last_error = exc
finally:
probe.close()
raise RuntimeError(f"Impossible de déterminer l’adresse locale du PC : {last_error}")
def handler_for(upstream: str, token: str):
class BridgeHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.0"
server_version = "CodexPiAIBridge/1"
def log_message(self, fmt: str, *args) -> None:
print(f"{self.client_address[0]} · {fmt % args}")
def _authorized(self) -> bool:
scheme, _, supplied = self.headers.get("Authorization", "").partition(" ")
return scheme.lower() == "bearer" and hmac.compare_digest(supplied, token)
def _json_error(self, status: int, message: str) -> None:
body = json.dumps({"error": message}, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _path(self) -> str:
return urllib.parse.urlsplit(self.path).path
def _proxy(self, method: str) -> None:
if not self._authorized():
self._json_error(401, "Jeton du bridge IA invalide")
return
path = self._path()
allowed = ALLOWED_GET if method == "GET" else ALLOWED_POST
if path not in allowed:
self._json_error(404, "Route Ollama non autorisée")
return
length = int(self.headers.get("Content-Length") or 0)
if length < 0 or length > MAX_REQUEST_BYTES:
self._json_error(413, "Requête trop volumineuse")
return
data = self.rfile.read(length) if length else None
headers = {"Accept": self.headers.get("Accept", "application/json"), "User-Agent": "CodexPi-AI-Bridge/1"}
if data is not None:
headers["Content-Type"] = self.headers.get("Content-Type", "application/json")
request = urllib.request.Request(
upstream + self.path,
data=data,
method=method,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=900) as response:
self.send_response(response.status)
self.send_header("Content-Type", response.headers.get("Content-Type", "application/json"))
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
self.end_headers()
while True:
chunk = response.read(64 * 1024)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
except urllib.error.HTTPError as exc:
body = exc.read(2_000_000)
self.send_response(exc.code)
self.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
return
except (OSError, urllib.error.URLError) as exc:
self._json_error(502, f"Ollama est indisponible : {exc}")
def do_GET(self) -> None:
if self._path() == "/codexpi/info":
if not self._authorized():
self._json_error(401, "Jeton du bridge IA invalide")
return
body = json.dumps(hardware_info(), ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
if self._path() == "/health":
if not self._authorized():
self._json_error(401, "Jeton du bridge IA invalide")
return
try:
models = ollama_models(upstream)
except RuntimeError as exc:
self._json_error(503, str(exc))
return
body = json.dumps({"ok": True, "runtime": "ollama", "models": len(models)}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
return
self._proxy("GET")
def do_POST(self) -> None:
self._proxy("POST")
return BridgeHandler
def create_server(listen: str, port: int, upstream: str, token: str) -> ThreadingHTTPServer:
return ThreadingHTTPServer((listen, port), handler_for(checked_url(upstream, local_only=True), token))
def pair(
pairing_path: Path,
config_path: Path,
listen: str,
port: int,
upstream: str,
name: str,
advertise_address: str | None,
) -> tuple[dict, ThreadingHTTPServer]:
pairing = json.loads(pairing_path.read_text(encoding="utf-8"))
server_url = checked_url(str(pairing["server"]))
ollama_models(upstream)
token = secrets.token_urlsafe(48)
server = create_server(listen, port, upstream, token)
thread = threading.Thread(target=server.serve_forever, name="codexpi-ai-bridge", daemon=True)
thread.start()
address = advertise_address or discover_address(server_url)
try:
result = json_request(server_url, "/api/local-ai/agent/pair", {
"pairing_id": pairing["pairing_id"],
"code": pairing["code"],
"name": name,
"platform": platform.platform()[:160],
"address": address,
"port": port,
"token": token,
})
except Exception:
server.shutdown()
server.server_close()
raise
config = {
"listen": listen,
"port": port,
"upstream": checked_url(upstream, local_only=True),
"token": token,
"bridge_id": result.get("id"),
"codexpi": server_url,
}
save_config(config_path, config)
print(f"PC associé à CodexPi : {name}")
print(f"Adresse privée annoncée : http://{address}:{port}")
print(f"Modèle par défaut : {result.get('default_model') or 'à choisir dans CodexPi'}")
print(f"Configuration privée : {config_path}")
return config, server
def main() -> int:
default_config = Path.home() / ".codexpi" / "ai-bridge.json"
parser = argparse.ArgumentParser(description="Relie CodexPi à Ollama sur ce PC sans exposer l’API Ollama brute.")
parser.add_argument("--pairing", type=Path, help="fichier .codexpi-ai téléchargé depuis Comptes IA")
parser.add_argument("--config", type=Path, default=default_config, help="configuration privée du bridge")
parser.add_argument("--listen", default="0.0.0.0", help="adresse d’écoute du bridge")
parser.add_argument("--port", type=int, default=11435, help="port privé du bridge")
parser.add_argument("--upstream", default="http://127.0.0.1:11434", help="adresse locale d’Ollama")
parser.add_argument("--name", default=socket.gethostname(), help="nom affiché dans CodexPi")
parser.add_argument("--advertise-address", help="adresse IP privée du PC visible depuis le Pi")
args = parser.parse_args()
if not 1024 <= args.port <= 65535:
parser.error("--port doit être compris entre 1024 et 65535")
active_server: ThreadingHTTPServer | None = None
background_server = False
try:
if args.pairing:
_, active_server = pair(
args.pairing,
args.config,
args.listen,
args.port,
args.upstream,
args.name,
args.advertise_address,
)
background_server = True
else:
config = json.loads(args.config.read_text(encoding="utf-8"))
ollama_models(str(config["upstream"]))
active_server = create_server(
str(config.get("listen") or args.listen),
int(config.get("port") or args.port),
str(config["upstream"]),
str(config["token"]),
)
print(f"Bridge IA CodexPi prêt sur le port {config.get('port') or args.port}")
print("Laissez cette fenêtre ouverte. Ctrl+C arrête le bridge.")
if background_server:
stop = threading.Event()
while not stop.wait(3600):
pass
else:
active_server.serve_forever()
return 0
except KeyboardInterrupt:
print("\nBridge IA arrêté.")
return 0
except Exception as exc:
print(f"Erreur : {exc}", file=sys.stderr)
return 1
finally:
if active_server is not None:
if background_server:
active_server.shutdown()
active_server.server_close()
if __name__ == "__main__":
raise SystemExit(main())