Skip to content
Open
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
153 changes: 123 additions & 30 deletions ebuild/eos_ai/llm_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
client = LLMClient(provider="openai", model="gpt-4o") # uses OPENAI_API_KEY env var

response = client.analyze(prompt)

Environment variables:
OLLAMA_HOST Base URL for Ollama (default: http://localhost:11434)
OLLAMA_MODEL Model name for Ollama (default: llama3)
OPENAI_API_KEY API key for OpenAI
OPENAI_BASE_URL Base URL for OpenAI-compatible endpoint (default: https://api.openai.com)
OPENAI_MODEL Model name for OpenAI (default: gpt-4o-mini)
EOS_LLM_API_KEY API key for custom provider
EOS_LLM_URL Base URL for custom provider
EOS_LLM_MODEL Model name for custom provider
"""

from __future__ import annotations
Expand All @@ -30,7 +40,7 @@
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Optional
from typing import Dict, Optional


@dataclass
Expand All @@ -44,6 +54,37 @@ class LLMResponse:
error: str = ""


def _normalize_openai_url(base_url: str) -> str:
"""Normalize a base URL to the OpenAI chat completions endpoint.

Handles all common forms users pass, preventing double path segments:

- ``https://api.openai.com`` → .../v1/chat/completions
- ``http://localhost:8000/v1`` → .../v1/chat/completions (no /v1/v1)
- ``http://localhost:8000/v1/`` → .../v1/chat/completions (trailing slash)
- ``http://localhost:8000/v1/chat/completions`` → unchanged (idempotent)
"""
url = base_url.rstrip("/")
if url.endswith("/chat/completions"):
return url
if url.endswith("/v1"):
return url + "/chat/completions"
return url + "/v1/chat/completions"


def _ensure_scheme(host: str, default_scheme: str = "http") -> str:
"""Prepend a scheme to a bare host string if one is missing.

Example::

_ensure_scheme("192.168.1.50:11434") # → "http://192.168.1.50:11434"
_ensure_scheme("http://localhost:11434") # → unchanged
"""
if "://" in host:
return host
return f"{default_scheme}://{host}"


class LLMClient:
"""Unified LLM client for hardware analysis.

Expand All @@ -66,38 +107,94 @@ def __init__(
timeout: int = 120,
):
self.provider = provider
self.model = model
self.api_key = api_key
self.base_url = base_url
self.timeout = timeout

if provider == "ollama":
self.base_url = base_url or self.OLLAMA_URL
# Respect OLLAMA_HOST env var; ensure scheme is present on bare hosts
ollama_host = os.environ.get("OLLAMA_HOST", self.OLLAMA_URL)
self.base_url = base_url or _ensure_scheme(ollama_host)
self.model = model if model != "llama3" else os.environ.get("OLLAMA_MODEL", "llama3")
self.api_key = ""

elif provider == "openai":
self.base_url = base_url or self.OPENAI_URL
# Respect OPENAI_BASE_URL env var for custom-hosted OpenAI-compatible endpoints
openai_base = os.environ.get("OPENAI_BASE_URL", self.OPENAI_URL)
self.base_url = base_url or openai_base
self.model = model if model != "llama3" else os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")

elif provider == "custom":
self.base_url = base_url or ""
self.base_url = base_url or os.environ.get("EOS_LLM_URL", "")
self.model = model if model != "llama3" else os.environ.get("EOS_LLM_MODEL", "default")
self.api_key = api_key or os.environ.get("EOS_LLM_API_KEY", "")

else:
self.base_url = base_url or ""
self.model = model
self.api_key = api_key or ""

def _build_headers(self) -> Dict[str, str]:
"""Build HTTP headers for OpenAI-compatible requests.

The ``Authorization`` header is only included when an API key is
present — local servers such as vLLM, llama.cpp, and LocalAI run
unauthenticated and reject a stray ``Bearer`` header.
"""
headers: Dict[str, str] = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers

def _parse_openai_response(self, body: dict) -> LLMResponse:
"""Parse an OpenAI-compatible chat completions response body.

Guards against servers that return an empty ``choices`` list rather
than an error status — previously this would silently succeed with
an empty text and ``success=True``, masking the upstream failure.
"""
choices = body.get("choices", [])
if not choices:
return LLMResponse(
text="", model=body.get("model", self.model),
provider=self.provider, success=False,
error="Upstream returned no completion choices.",
)
content = choices[0].get("message", {}).get("content", "")
if not content:
return LLMResponse(
text="", model=body.get("model", self.model),
provider=self.provider, success=False,
error="Upstream returned an empty completion.",
)
usage = body.get("usage", {})
return LLMResponse(
text=content,
model=body.get("model", self.model),
provider=self.provider,
tokens_used=usage.get("total_tokens", 0),
success=True,
)

@classmethod
def auto(cls) -> "LLMClient":
"""Auto-detect available LLM provider.

Priority:
1. Ollama running locally
1. Ollama running locally (or at OLLAMA_HOST)
2. OpenAI API key in environment
3. EOS_LLM_API_KEY + EOS_LLM_URL in environment
4. None (returns a client that will fail gracefully)
"""
# Try Ollama
if cls._check_ollama():
return cls(provider="ollama", model="llama3")
model = os.environ.get("OLLAMA_MODEL", "llama3")
return cls(provider="ollama", model=model)

# Try OpenAI
openai_key = os.environ.get("OPENAI_API_KEY", "")
if openai_key:
return cls(provider="openai", model="gpt-4o-mini", api_key=openai_key)
model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
return cls(provider="openai", model=model, api_key=openai_key)

# Try custom
custom_key = os.environ.get("EOS_LLM_API_KEY", "")
Expand All @@ -112,10 +209,12 @@ def auto(cls) -> "LLMClient":

@staticmethod
def _check_ollama() -> bool:
"""Check if Ollama is running locally."""
"""Check if Ollama is running (locally or at OLLAMA_HOST)."""
ollama_host = os.environ.get("OLLAMA_HOST", LLMClient.OLLAMA_URL)
base = _ensure_scheme(ollama_host)
try:
req = urllib.request.Request(
f"{LLMClient.OLLAMA_URL}/api/tags",
f"{base}/api/tags",
method="GET",
)
with urllib.request.urlopen(req, timeout=3) as resp:
Expand Down Expand Up @@ -198,8 +297,14 @@ def _call_ollama(self, prompt: str, system: str) -> LLMResponse:
)

def _call_openai_compat(self, prompt: str, system: str) -> LLMResponse:
"""Call OpenAI-compatible chat completions API."""
url = f"{self.base_url}/v1/chat/completions"
"""Call OpenAI-compatible chat completions API.

The endpoint URL is normalised via :func:`_normalize_openai_url` so
that a ``base_url`` already containing ``/v1`` (e.g. from
``OPENAI_BASE_URL=http://localhost:8000/v1``) does not produce a
doubled path segment (``/v1/v1/chat/completions``).
"""
url = _normalize_openai_url(self.base_url or self.OPENAI_URL)
payload = {
"model": self.model,
"messages": [
Expand All @@ -211,26 +316,14 @@ def _call_openai_compat(self, prompt: str, system: str) -> LLMResponse:
}

data = json.dumps(payload).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
req = urllib.request.Request(
url, data=data, headers=self._build_headers(), method="POST"
)

with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))

choice = body.get("choices", [{}])[0]
message = choice.get("message", {})
usage = body.get("usage", {})

return LLMResponse(
text=message.get("content", ""),
model=body.get("model", self.model),
provider=self.provider,
tokens_used=usage.get("total_tokens", 0),
success=True,
)
return self._parse_openai_response(body)

def get_provider_info(self) -> str:
"""Return human-readable provider information."""
Expand Down
35 changes: 35 additions & 0 deletions ebuild/packages/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,41 @@ def validate(self) -> None:
f"Must be one of {self.VALID_BUILD_SYSTEMS}."
)

def to_dict(self) -> Dict[str, Any]:
"""Serialize this recipe to a dict using the canonical YAML schema.

Keys match the *external* YAML format (``package``, ``build``, etc.)
rather than the internal dataclass field names (``name``,
``build_system``), so the result round-trips cleanly through
:func:`parse_recipe`.

Only non-empty optional fields are included to keep the output
minimal and readable.
"""
data: Dict[str, Any] = {
"package": self.name,
"version": self.version,
"url": self.url,
"build": self.build_system,
}
if self.checksum:
data["checksum"] = self.checksum
if self.dependencies:
data["dependencies"] = list(self.dependencies)
if self.patches:
data["patches"] = list(self.patches)
if self.configure_args:
data["configure_args"] = list(self.configure_args)
if self.build_args:
data["build_args"] = list(self.build_args)
if self.install_args:
data["install_args"] = list(self.install_args)
if self.description:
data["description"] = self.description
if self.license:
data["license"] = self.license
return data


def _parse_string_list(
raw: Dict[str, Any],
Expand Down
2 changes: 1 addition & 1 deletion ebuild/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def discover_plugins() -> List[PluginBase]:
else:
# Before 3.10 entry_points() returned a dict; the current stubs
# only model EntryPoints, which has no .get, hence the ignore.
eps = entry_points.get("ebuild.plugins", []) # type: ignore[attr-defined]
eps = entry_points.get("ebuild.plugins", []) # type: ignore[arg-type]

for ep in eps:
try:
Expand Down
1 change: 0 additions & 1 deletion tests/ebuild/test_build_dir_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import os
import shutil
import subprocess
import shutil
import textwrap
from pathlib import Path
from types import SimpleNamespace
Expand Down
2 changes: 1 addition & 1 deletion tests/ebuild/test_package_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,4 @@ def test_depends_alias_must_be_a_list():
"""

with pytest.raises(RecipeError, match="dependencies"):
load_recipe_from_string(content)
load_recipe_from_string(content)
6 changes: 4 additions & 2 deletions tests/unit/test_ci_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
required check does not cover.
"""

import itertools
import re

import yaml
import pytest
from pathlib import Path
Expand Down Expand Up @@ -211,8 +214,7 @@ def test_gate_fails_on_any_non_success_result(jobs):
# check cannot say which of the three it means, and a Windows-only failure is
# indistinguishable from the other two legs without opening the run.

import itertools
import re


# `include` and `exclude` shape a matrix but are not dimensions of it, so they
# are not part of the cartesian product.
Expand Down
Loading
Loading