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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ on:

jobs:
test:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]

steps:
Expand All @@ -25,7 +26,7 @@ jobs:
cache: pip

- name: Install package
run: python -m pip install -e '.[dev]'
run: python -m pip install -e ".[dev]"

- name: Lint
run: ruff check .
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ classifiers = [
"Development Status :: 4 - Beta",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
Expand Down
8 changes: 6 additions & 2 deletions src/arc_llama/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import json
import os
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -391,6 +392,9 @@ def run_command(command: str, root: Path, timeout: float = 60.0) -> ToolResult:
f"Error: git history-mutating command '{bad_prefix}' is not allowed.",
error=True,
)
env = os.environ.copy()
if sys.platform != "win32":
env.update({"PS1": "", "TERM": "dumb"})
try:
result = subprocess.run(
command,
Expand All @@ -399,7 +403,7 @@ def run_command(command: str, root: Path, timeout: float = 60.0) -> ToolResult:
capture_output=True,
text=True,
timeout=timeout,
env={**os.environ, "PS1": "", "TERM": "dumb"},
env=env,
)
except subprocess.TimeoutExpired:
return ToolResult(
Expand Down Expand Up @@ -454,7 +458,7 @@ def search_files(pattern: str, root: Path, path_glob: str = "*") -> ToolResult:
with p.open("r", encoding="utf-8", errors="ignore") as f:
for i, line in enumerate(f, start=1):
if pattern in line:
rel = p.relative_to(root)
rel = p.relative_to(root).as_posix()
matches.append(f"{rel}:{i}: {line.rstrip()}")
except OSError:
continue
Expand Down
7 changes: 2 additions & 5 deletions src/arc_llama/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,15 @@
"""
from __future__ import annotations

import json
import logging
import statistics
import time
from dataclasses import asdict, dataclass, field
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

import httpx

from arc_llama.config import Config, ModelConfig, load_config
from arc_llama.recipes import KVCacheType, default_recipe, suggest_ctx
from arc_llama.config import Config, load_config

log = logging.getLogger("arc_llama.benchmark")

Expand Down
47 changes: 45 additions & 2 deletions src/arc_llama/chat_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def to_dict(self) -> dict[str, Any]:
return {"role": self.role, "content": self.content, "timestamp": self.timestamp}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChatMessage":
def from_dict(cls, data: dict[str, Any]) -> ChatMessage:
return cls(
role=data.get("role", ""),
content=data.get("content", ""),
Expand Down Expand Up @@ -55,7 +55,7 @@ def to_dict(self) -> dict[str, Any]:
}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Chat":
def from_dict(cls, data: dict[str, Any]) -> Chat:
return cls(
id=data.get("id", ""),
title=data.get("title", "Untitled chat"),
Expand Down Expand Up @@ -170,3 +170,46 @@ def wipe(self) -> None:
if self.directory.exists():
shutil.rmtree(self.directory)
self.directory.mkdir(parents=True, exist_ok=True)

def export_all(self) -> list[dict[str, Any]]:
"""Return every stored chat as a list of plain dicts."""
return [chat.to_dict() for chat in self.list_chats()]

def import_chats(
self,
data: list[dict[str, Any]],
*,
overwrite: bool = False,
) -> dict[str, int | list[str]]:
"""Import a list of chat dicts.

Args:
data: list of chat dicts in the format produced by ``Chat.to_dict``.
overwrite: if True, replace an existing chat with the same id.

Returns:
A summary dict with ``imported``, ``skipped``, and ``errors`` counts.
"""
imported = 0
skipped = 0
errors: list[str] = []
for item in data:
if not isinstance(item, dict):
errors.append("skipped non-dict entry")
continue
chat_id = item.get("id")
if not chat_id:
errors.append("skipped chat with missing id")
continue
path = self._chat_path(chat_id)
if path.exists() and not overwrite:
skipped += 1
continue
try:
chat = Chat.from_dict(item)
except (TypeError, ValueError) as e:
errors.append(f"{chat_id}: invalid chat data ({e})")
continue
self._save(chat)
imported += 1
return {"imported": imported, "skipped": skipped, "errors": len(errors), "error_details": errors}
138 changes: 101 additions & 37 deletions src/arc_llama/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"""
from __future__ import annotations

import json
import logging
import os
import platform
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -46,13 +48,38 @@

console = Console()

_IS_WINDOWS = sys.platform == "win32"


class _JsonFormatter(logging.Formatter):
"""Emit log records as single-line JSON objects."""

def format(self, record: logging.LogRecord) -> str:
obj = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"name": record.name,
"message": record.getMessage(),
}
if record.exc_info:
obj["exception"] = self.formatException(record.exc_info)
return json.dumps(obj, default=str)


def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if os.environ.get("ARC_LLAMA_LOG_JSON"):
handler = logging.StreamHandler()
handler.setFormatter(_JsonFormatter())
root = logging.getLogger()
root.setLevel(level)
root.handlers.clear()
root.addHandler(handler)
else:
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)


def _save_or_die(cfg: Config, path: Path) -> None:
Expand Down Expand Up @@ -137,8 +164,14 @@ def init(
sys.exit(1)
gpus = detect_gpus()
if not gpus:
console.print("[red]No Intel GPUs detected.[/red]")
console.print("Run [bold]arc-llama doctor[/bold] for a diagnosis.")
if _IS_WINDOWS:
console.print(
"[yellow]No Intel GPUs detected — Windows auto-detection is not "
"supported yet. Create a config manually or run this on WSL.[/yellow]"
)
else:
console.print("[red]No Intel GPUs detected.[/red]")
console.print("Run [bold]arc-llama doctor[/bold] for a diagnosis.")
sys.exit(2)
server_path = _resolve_llama_server(llama_server)
cfg = init_config_from_detection(gpus, llama_server_path=server_path)
Expand Down Expand Up @@ -178,12 +211,19 @@ def doctor(ctx: click.Context) -> None:
config_path: Path = ctx.obj["config_path"]
console.print("[bold]arc-llama doctor[/bold]\n")

# Kernel + driver
console.print(f" kernel: {os.uname().release}")
has_xe = Path("/sys/module/xe").exists()
has_i915 = Path("/sys/module/i915").exists()
console.print(f" xe driver: {'loaded' if has_xe else 'not loaded'}")
console.print(f" i915 driver: {'loaded' if has_i915 else 'not loaded'}")
# Kernel + driver (Linux-only diagnostics)
if _IS_WINDOWS:
console.print(f" platform: Windows {platform.release()}")
console.print(
" [dim]Kernel/driver checks are not available on Windows.[/dim]"
)
else:
uname = platform.uname()
console.print(f" kernel: {uname.release}")
has_xe = Path("/sys/module/xe").exists()
has_i915 = Path("/sys/module/i915").exists()
console.print(f" xe driver: {'loaded' if has_xe else 'not loaded'}")
console.print(f" i915 driver: {'loaded' if has_i915 else 'not loaded'}")

# GPU detection (enrich=True so clinfo populates VRAM where xe doesn't via sysfs)
gpus = detect_gpus(enrich=True)
Expand Down Expand Up @@ -214,33 +254,49 @@ def doctor(ctx: click.Context) -> None:
path = shutil.which(tool)
console.print(f" {tool:<14} {path or '— missing —'}")

# Permissions
console.print("\n user groups:")
try:
out = subprocess.run(["id", "-nG"], capture_output=True, text=True, timeout=2)
groups = out.stdout.split()
except (FileNotFoundError, subprocess.TimeoutExpired):
groups = []
for needed in ("render", "video"):
ok = needed in groups
marker = "[green]ok[/green]" if ok else "[yellow]missing[/yellow]"
console.print(f" {needed:<14} {marker}")
if "render" not in groups or "video" not in groups:
console.print(
" [yellow]→ add yourself with `sudo usermod -aG render,video $USER` "
"and re-login.[/yellow]"
)
# Permissions (Linux-only)
if _IS_WINDOWS:
console.print("\n user groups:")
console.print(" [dim]Group checks are not available on Windows.[/dim]")
else:
console.print("\n user groups:")
try:
out = subprocess.run(["id", "-nG"], capture_output=True, text=True, timeout=2)
groups = out.stdout.split()
except (FileNotFoundError, subprocess.TimeoutExpired):
groups = []
for needed in ("render", "video"):
ok = needed in groups
marker = "[green]ok[/green]" if ok else "[yellow]missing[/yellow]"
console.print(f" {needed:<14} {marker}")
if "render" not in groups or "video" not in groups:
console.print(
" [yellow]→ add yourself with `sudo usermod -aG render,video $USER` "
"and re-login.[/yellow]"
)

# oneAPI
oneapi_setvars = Path("/opt/intel/oneapi/setvars.sh")
console.print("\n oneAPI:")
if oneapi_setvars.exists():
console.print(f" setvars.sh: {oneapi_setvars}")
if _IS_WINDOWS:
oneapi_setvars = Path(
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
) / "Intel" / "oneAPI" / "setvars.bat"
if oneapi_setvars.exists():
console.print(f" setvars.bat: {oneapi_setvars}")
else:
console.print(
" [yellow]Intel oneAPI setvars.bat not found — install Intel "
"oneAPI Base Toolkit if you're building llama.cpp from source.[/yellow]"
)
else:
console.print(
" [yellow]/opt/intel/oneapi/setvars.sh missing — install Intel "
"oneAPI Base Toolkit if you're building llama.cpp from source.[/yellow]"
)
oneapi_setvars = Path("/opt/intel/oneapi/setvars.sh")
if oneapi_setvars.exists():
console.print(f" setvars.sh: {oneapi_setvars}")
else:
console.print(
" [yellow]/opt/intel/oneapi/setvars.sh missing — install Intel "
"oneAPI Base Toolkit if you're building llama.cpp from source.[/yellow]"
)

# Config
console.print("\n config:")
Expand Down Expand Up @@ -604,9 +660,14 @@ def _on_signal(signum: int, _frame) -> None: # noqa: ANN001
_shutdown_subprocesses()
# Re-raise as default so uvicorn's own handler (or python) finishes the job.
_signal.signal(signum, _signal.SIG_DFL)
os.kill(os.getpid(), signum)
if _IS_WINDOWS:
sys.exit(0)
else:
os.kill(os.getpid(), signum)

for s in (_signal.SIGTERM, _signal.SIGINT):
for s in (getattr(_signal, "SIGTERM", None), _signal.SIGINT):
if s is None:
continue
try:
_signal.signal(s, _on_signal)
except (OSError, ValueError):
Expand Down Expand Up @@ -643,6 +704,9 @@ def mtp_info_cmd(path: Path) -> None:
@click.option("--write", is_flag=True, help="Write the unit to ~/.config/systemd/user/")
def systemd_unit(service_name: str, description: str, write: bool) -> None:
"""Print (or write) a systemd --user unit for `arc-llama serve`."""
if _IS_WINDOWS:
console.print("[red]systemd is not available on Windows.[/red]")
sys.exit(1)
arc = shutil.which("arc-llama")
if not arc:
arc = str(Path(sys.argv[0]).resolve())
Expand Down
Loading
Loading