opencode-llama-cpp-launcher 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_llama_cpp_launcher/__init__.py +3 -0
- opencode_llama_cpp_launcher/cli/__init__.py +1 -0
- opencode_llama_cpp_launcher/cli/entrypoint.py +114 -0
- opencode_llama_cpp_launcher/models/__init__.py +1 -0
- opencode_llama_cpp_launcher/models/launch_config.py +43 -0
- opencode_llama_cpp_launcher/models/launch_status.py +14 -0
- opencode_llama_cpp_launcher/services/__init__.py +1 -0
- opencode_llama_cpp_launcher/services/binaries.py +12 -0
- opencode_llama_cpp_launcher/services/errors.py +5 -0
- opencode_llama_cpp_launcher/services/health.py +59 -0
- opencode_llama_cpp_launcher/services/launch_config_loader.py +105 -0
- opencode_llama_cpp_launcher/services/launch_preparer.py +130 -0
- opencode_llama_cpp_launcher/services/launcher.py +197 -0
- opencode_llama_cpp_launcher/services/opencode_config.py +34 -0
- opencode_llama_cpp_launcher/services/ports.py +18 -0
- opencode_llama_cpp_launcher/storage/__init__.py +1 -0
- opencode_llama_cpp_launcher/storage/config_loader.py +134 -0
- opencode_llama_cpp_launcher-0.1.0.dist-info/METADATA +127 -0
- opencode_llama_cpp_launcher-0.1.0.dist-info/RECORD +22 -0
- opencode_llama_cpp_launcher-0.1.0.dist-info/WHEEL +4 -0
- opencode_llama_cpp_launcher-0.1.0.dist-info/entry_points.txt +2 -0
- opencode_llama_cpp_launcher-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI entrypoints."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from opencode_llama_cpp_launcher.services.binaries import require_binary
|
|
8
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
9
|
+
from opencode_llama_cpp_launcher.services.launch_config_loader import LaunchConfigLoader
|
|
10
|
+
from opencode_llama_cpp_launcher.services.launcher import Launcher
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
15
|
+
invoke_without_command=True,
|
|
16
|
+
no_args_is_help=False,
|
|
17
|
+
help="Launch OpenCode against a local llama.cpp GGUF model.",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback()
|
|
22
|
+
def launch_callback(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
model: Path | None = typer.Option(
|
|
25
|
+
None,
|
|
26
|
+
"--model",
|
|
27
|
+
"-m",
|
|
28
|
+
help="GGUF model path. Overrides YAML config.",
|
|
29
|
+
),
|
|
30
|
+
llama_server: Path | None = typer.Option(
|
|
31
|
+
None,
|
|
32
|
+
"--llama-server",
|
|
33
|
+
help="llama-server binary path. Overrides YAML config and PATH lookup.",
|
|
34
|
+
),
|
|
35
|
+
project: Path = typer.Option(
|
|
36
|
+
Path("."),
|
|
37
|
+
"--project",
|
|
38
|
+
"-p",
|
|
39
|
+
help="Project directory where OpenCode should run.",
|
|
40
|
+
),
|
|
41
|
+
config: Path | None = typer.Option(
|
|
42
|
+
None,
|
|
43
|
+
"--config",
|
|
44
|
+
"-f",
|
|
45
|
+
help="Path to opencode-llama.yaml.",
|
|
46
|
+
),
|
|
47
|
+
port: int | None = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--port",
|
|
50
|
+
help="Preferred llama-server port.",
|
|
51
|
+
),
|
|
52
|
+
ctx_size: int | None = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--ctx-size",
|
|
55
|
+
help="llama-server context size.",
|
|
56
|
+
),
|
|
57
|
+
dry_run: bool = typer.Option(
|
|
58
|
+
False,
|
|
59
|
+
"--dry-run",
|
|
60
|
+
help="Print resolved commands and generated config without launching.",
|
|
61
|
+
),
|
|
62
|
+
) -> None:
|
|
63
|
+
if ctx.invoked_subcommand is not None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
launch_config = LaunchConfigLoader().load(
|
|
68
|
+
model=model,
|
|
69
|
+
llama_server=llama_server,
|
|
70
|
+
project=project,
|
|
71
|
+
config=config,
|
|
72
|
+
port=port,
|
|
73
|
+
ctx_size=ctx_size,
|
|
74
|
+
dry_run=dry_run,
|
|
75
|
+
)
|
|
76
|
+
raise typer.Exit(_shell_exit_code(Launcher().run(launch_config)))
|
|
77
|
+
except LauncherError as exc:
|
|
78
|
+
_print_error(str(exc))
|
|
79
|
+
raise typer.Exit(1) from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def doctor() -> None:
|
|
84
|
+
"""Check whether required external binaries are available."""
|
|
85
|
+
failed = False
|
|
86
|
+
|
|
87
|
+
for binary_name in ("llama-server", "opencode"):
|
|
88
|
+
try:
|
|
89
|
+
binary_path = require_binary(binary_name)
|
|
90
|
+
typer.echo(f"OK {binary_name}: {binary_path}")
|
|
91
|
+
except LauncherError as exc:
|
|
92
|
+
failed = True
|
|
93
|
+
typer.echo(f"Missing {binary_name}: {exc}", err=True)
|
|
94
|
+
|
|
95
|
+
if failed:
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _print_error(message: str) -> None:
|
|
100
|
+
typer.echo(f"Error: {message}", err=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _shell_exit_code(return_code: int) -> int:
|
|
104
|
+
if return_code >= 0:
|
|
105
|
+
return return_code
|
|
106
|
+
return 128 + abs(return_code)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
app()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data models for launcher configuration and status."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
8
|
+
DEFAULT_PORT = 8080
|
|
9
|
+
MAX_PORT = 65535
|
|
10
|
+
DEFAULT_CTX_SIZE = 8192
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LaunchConfig:
|
|
15
|
+
project: Path
|
|
16
|
+
model_path: Path
|
|
17
|
+
llama_server_path: Path | None = None
|
|
18
|
+
port: int = DEFAULT_PORT
|
|
19
|
+
ctx_size: int = DEFAULT_CTX_SIZE
|
|
20
|
+
host: str = DEFAULT_HOST
|
|
21
|
+
dry_run: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class LlamaServerConfig:
|
|
26
|
+
model_path: Path
|
|
27
|
+
command: list[str]
|
|
28
|
+
selected_port: int
|
|
29
|
+
root_url: str
|
|
30
|
+
base_url: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class OpenCodeConfig:
|
|
35
|
+
command: list[str]
|
|
36
|
+
config: dict
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class BuiltLaunchConfig:
|
|
41
|
+
project: Path
|
|
42
|
+
llama_server: LlamaServerConfig
|
|
43
|
+
opencode: OpenCodeConfig
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LaunchStatus(StrEnum):
|
|
7
|
+
IDLE = "Idle"
|
|
8
|
+
CHECKING_PREREQUISITES = "Checking prerequisites"
|
|
9
|
+
STARTING_LLAMA_SERVER = "Starting llama-server"
|
|
10
|
+
WAITING_FOR_LLAMA_SERVER = "Waiting for llama-server"
|
|
11
|
+
DETECTING_MODEL = "Detecting model"
|
|
12
|
+
STARTING_OPENCODE = "Starting OpenCode"
|
|
13
|
+
READY = "Ready"
|
|
14
|
+
ERROR = "Error"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Launcher services."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def require_binary(name: str) -> str:
|
|
9
|
+
binary_path = shutil.which(name)
|
|
10
|
+
if binary_path is None:
|
|
11
|
+
raise LauncherError(f"`{name}` was not found in PATH.")
|
|
12
|
+
return binary_path
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def wait_for_health(
|
|
13
|
+
root_url: str,
|
|
14
|
+
timeout_seconds: float = 120.0,
|
|
15
|
+
interval_seconds: float = 0.5,
|
|
16
|
+
is_server_running: Callable[[], bool] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
deadline = time.monotonic() + timeout_seconds
|
|
19
|
+
last_error: Exception | None = None
|
|
20
|
+
|
|
21
|
+
while time.monotonic() < deadline:
|
|
22
|
+
if is_server_running is not None and not is_server_running():
|
|
23
|
+
raise LauncherError("llama-server exited before becoming ready.")
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
with urllib.request.urlopen(f"{root_url}/health", timeout=2.0) as response:
|
|
27
|
+
if response.status == 200:
|
|
28
|
+
return
|
|
29
|
+
except OSError as exc:
|
|
30
|
+
last_error = exc
|
|
31
|
+
|
|
32
|
+
if is_server_running is not None and not is_server_running():
|
|
33
|
+
raise LauncherError("llama-server exited before becoming ready.")
|
|
34
|
+
|
|
35
|
+
time.sleep(interval_seconds)
|
|
36
|
+
|
|
37
|
+
suffix = f" Last error: {last_error}" if last_error else ""
|
|
38
|
+
raise LauncherError(f"llama-server did not become ready at {root_url}.{suffix}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch_first_model_id(base_url: str) -> str:
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(f"{base_url}/models", timeout=5.0) as response:
|
|
44
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
45
|
+
except urllib.error.URLError as exc:
|
|
46
|
+
raise LauncherError(f"Could not query llama-server models: {exc}") from exc
|
|
47
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
48
|
+
raise LauncherError("llama-server returned invalid JSON from /v1/models.") from exc
|
|
49
|
+
|
|
50
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
51
|
+
if not isinstance(data, list) or not data:
|
|
52
|
+
raise LauncherError("No models returned from llama-server /v1/models.")
|
|
53
|
+
|
|
54
|
+
first_model = data[0]
|
|
55
|
+
model_id = first_model.get("id") if isinstance(first_model, dict) else None
|
|
56
|
+
if not isinstance(model_id, str) or not model_id:
|
|
57
|
+
raise LauncherError("First llama-server model entry did not include an id.")
|
|
58
|
+
|
|
59
|
+
return model_id
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from opencode_llama_cpp_launcher.models.launch_config import LaunchConfig, MAX_PORT
|
|
6
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
7
|
+
from opencode_llama_cpp_launcher.storage.config_loader import (
|
|
8
|
+
CONFIG_TEMPLATE,
|
|
9
|
+
FileConfig,
|
|
10
|
+
load_file_config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LaunchConfigLoader:
|
|
15
|
+
"""Merge CLI options and YAML defaults into the launch request."""
|
|
16
|
+
|
|
17
|
+
def load(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
model: Path | None,
|
|
21
|
+
llama_server: Path | None,
|
|
22
|
+
project: Path,
|
|
23
|
+
config: Path | None,
|
|
24
|
+
port: int | None,
|
|
25
|
+
ctx_size: int | None,
|
|
26
|
+
dry_run: bool,
|
|
27
|
+
) -> LaunchConfig:
|
|
28
|
+
project_path = self._absolute_path(project)
|
|
29
|
+
file_config = load_file_config(project_path, config)
|
|
30
|
+
|
|
31
|
+
# CLI flags should always win over values remembered in the project file.
|
|
32
|
+
model_path = self._model_path_from_cli_or_config(model, file_config)
|
|
33
|
+
llama_server_path = self._llama_server_path_from_cli_or_config(
|
|
34
|
+
llama_server,
|
|
35
|
+
file_config,
|
|
36
|
+
)
|
|
37
|
+
selected_port = self._port_from_cli_or_config(port, file_config)
|
|
38
|
+
selected_ctx_size = self._ctx_size_from_cli_or_config(ctx_size, file_config)
|
|
39
|
+
|
|
40
|
+
if model_path is None:
|
|
41
|
+
self._raise_missing_model()
|
|
42
|
+
|
|
43
|
+
self._validate_port(selected_port)
|
|
44
|
+
self._validate_ctx_size(selected_ctx_size)
|
|
45
|
+
|
|
46
|
+
return LaunchConfig(
|
|
47
|
+
project=project_path,
|
|
48
|
+
model_path=model_path,
|
|
49
|
+
llama_server_path=llama_server_path,
|
|
50
|
+
port=selected_port,
|
|
51
|
+
ctx_size=selected_ctx_size,
|
|
52
|
+
dry_run=dry_run,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _model_path_from_cli_or_config(
|
|
56
|
+
self,
|
|
57
|
+
model: Path | None,
|
|
58
|
+
file_config: FileConfig,
|
|
59
|
+
) -> Path | None:
|
|
60
|
+
if model is not None:
|
|
61
|
+
return self._absolute_path(model)
|
|
62
|
+
if file_config.model is not None:
|
|
63
|
+
return self._absolute_path(file_config.model)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def _llama_server_path_from_cli_or_config(
|
|
67
|
+
self,
|
|
68
|
+
llama_server: Path | None,
|
|
69
|
+
file_config: FileConfig,
|
|
70
|
+
) -> Path | None:
|
|
71
|
+
if llama_server is not None:
|
|
72
|
+
return self._absolute_path(llama_server)
|
|
73
|
+
if file_config.llama_server_path is not None:
|
|
74
|
+
return self._absolute_path(file_config.llama_server_path)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _port_from_cli_or_config(self, port: int | None, file_config: FileConfig) -> int:
|
|
78
|
+
return port if port is not None else file_config.port
|
|
79
|
+
|
|
80
|
+
def _ctx_size_from_cli_or_config(
|
|
81
|
+
self,
|
|
82
|
+
ctx_size: int | None,
|
|
83
|
+
file_config: FileConfig,
|
|
84
|
+
) -> int:
|
|
85
|
+
return ctx_size if ctx_size is not None else file_config.ctx_size
|
|
86
|
+
|
|
87
|
+
def _validate_port(self, port: int) -> None:
|
|
88
|
+
if not 0 < port <= MAX_PORT:
|
|
89
|
+
raise LauncherError(f"Port must be between 1 and {MAX_PORT}.")
|
|
90
|
+
|
|
91
|
+
def _validate_ctx_size(self, ctx_size: int) -> None:
|
|
92
|
+
if ctx_size <= 0:
|
|
93
|
+
raise LauncherError("Context size must be greater than zero.")
|
|
94
|
+
|
|
95
|
+
def _raise_missing_model(self) -> None:
|
|
96
|
+
raise LauncherError(
|
|
97
|
+
"No GGUF model path was provided.\n\n"
|
|
98
|
+
"Pass one with `--model /absolute/path/to/model.gguf` or create:\n\n"
|
|
99
|
+
f"{CONFIG_TEMPLATE}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _absolute_path(self, path: Path) -> Path:
|
|
103
|
+
# Normalize paths once at the boundary so downstream services can compare
|
|
104
|
+
# and execute them without repeating expanduser/relative-path handling.
|
|
105
|
+
return path.expanduser().resolve()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from opencode_llama_cpp_launcher.models.launch_config import (
|
|
8
|
+
LaunchConfig,
|
|
9
|
+
BuiltLaunchConfig,
|
|
10
|
+
LlamaServerConfig,
|
|
11
|
+
OpenCodeConfig,
|
|
12
|
+
)
|
|
13
|
+
from opencode_llama_cpp_launcher.services.binaries import require_binary
|
|
14
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
15
|
+
from opencode_llama_cpp_launcher.services.opencode_config import (
|
|
16
|
+
build_opencode_config as build_opencode_provider_config,
|
|
17
|
+
)
|
|
18
|
+
from opencode_llama_cpp_launcher.services.ports import select_port
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ServerUrls:
|
|
23
|
+
root: str
|
|
24
|
+
base: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LaunchPreparer:
|
|
28
|
+
"""Turn the launch request into commands, URLs, and provider config."""
|
|
29
|
+
|
|
30
|
+
def prepare(self, config: LaunchConfig) -> BuiltLaunchConfig:
|
|
31
|
+
self._validate_launch_targets(config)
|
|
32
|
+
|
|
33
|
+
llama_server_binary = self._llama_server_binary(config)
|
|
34
|
+
opencode_binary = require_binary("opencode")
|
|
35
|
+
selected_port = select_port(config.host, config.port)
|
|
36
|
+
urls = self._server_urls(config.host, selected_port)
|
|
37
|
+
|
|
38
|
+
return BuiltLaunchConfig(
|
|
39
|
+
project=config.project,
|
|
40
|
+
llama_server=self._build_llama_server_config(
|
|
41
|
+
config=config,
|
|
42
|
+
llama_server_binary=llama_server_binary,
|
|
43
|
+
selected_port=selected_port,
|
|
44
|
+
urls=urls,
|
|
45
|
+
),
|
|
46
|
+
opencode=self._build_opencode_config(
|
|
47
|
+
opencode_binary=opencode_binary,
|
|
48
|
+
base_url=urls.base,
|
|
49
|
+
model_id=config.model_path.stem,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _build_llama_server_config(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
config: LaunchConfig,
|
|
57
|
+
llama_server_binary: str,
|
|
58
|
+
selected_port: int,
|
|
59
|
+
urls: ServerUrls,
|
|
60
|
+
) -> LlamaServerConfig:
|
|
61
|
+
return LlamaServerConfig(
|
|
62
|
+
model_path=config.model_path,
|
|
63
|
+
command=self._llama_server_command(
|
|
64
|
+
llama_server_binary=llama_server_binary,
|
|
65
|
+
config=config,
|
|
66
|
+
selected_port=selected_port,
|
|
67
|
+
),
|
|
68
|
+
selected_port=selected_port,
|
|
69
|
+
root_url=urls.root,
|
|
70
|
+
base_url=urls.base,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _build_opencode_config(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
opencode_binary: str,
|
|
77
|
+
base_url: str,
|
|
78
|
+
model_id: str,
|
|
79
|
+
) -> OpenCodeConfig:
|
|
80
|
+
return OpenCodeConfig(
|
|
81
|
+
command=[opencode_binary],
|
|
82
|
+
config=build_opencode_provider_config(base_url, model_id),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _llama_server_binary(self, config: LaunchConfig) -> str:
|
|
86
|
+
if config.llama_server_path is None:
|
|
87
|
+
return require_binary("llama-server")
|
|
88
|
+
|
|
89
|
+
# A configured binary path is intentional, so fail clearly instead of
|
|
90
|
+
# silently falling back to a different llama-server on PATH.
|
|
91
|
+
return self._checked_binary_path("llama-server", config.llama_server_path)
|
|
92
|
+
|
|
93
|
+
def _checked_binary_path(self, name: str, path: Path) -> str:
|
|
94
|
+
if not path.exists() or not path.is_file():
|
|
95
|
+
raise LauncherError(f"`{name}` binary does not exist: {path}")
|
|
96
|
+
if not os.access(path, os.X_OK):
|
|
97
|
+
raise LauncherError(f"`{name}` binary is not executable: {path}")
|
|
98
|
+
|
|
99
|
+
return str(path)
|
|
100
|
+
|
|
101
|
+
def _validate_launch_targets(self, config: LaunchConfig) -> None:
|
|
102
|
+
if not config.project.exists() or not config.project.is_dir():
|
|
103
|
+
raise LauncherError(f"Project directory does not exist: {config.project}")
|
|
104
|
+
if not config.model_path.exists() or not config.model_path.is_file():
|
|
105
|
+
raise LauncherError(
|
|
106
|
+
f"Selected GGUF file does not exist: {config.model_path}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _llama_server_command(
|
|
110
|
+
self,
|
|
111
|
+
llama_server_binary: str,
|
|
112
|
+
config: LaunchConfig,
|
|
113
|
+
selected_port: int,
|
|
114
|
+
) -> list[str]:
|
|
115
|
+
return [
|
|
116
|
+
llama_server_binary,
|
|
117
|
+
"-m",
|
|
118
|
+
str(config.model_path),
|
|
119
|
+
"--host",
|
|
120
|
+
config.host,
|
|
121
|
+
"--port",
|
|
122
|
+
str(selected_port),
|
|
123
|
+
"-c",
|
|
124
|
+
str(config.ctx_size),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def _server_urls(self, host: str, port: int) -> ServerUrls:
|
|
128
|
+
root_url = f"http://{host}:{port}"
|
|
129
|
+
# llama-server's OpenAI-compatible routes live under /v1.
|
|
130
|
+
return ServerUrls(root=root_url, base=f"{root_url}/v1")
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import BinaryIO
|
|
9
|
+
|
|
10
|
+
from opencode_llama_cpp_launcher.models.launch_config import (
|
|
11
|
+
BuiltLaunchConfig,
|
|
12
|
+
LaunchConfig,
|
|
13
|
+
)
|
|
14
|
+
from opencode_llama_cpp_launcher.models.launch_status import LaunchStatus
|
|
15
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
16
|
+
from opencode_llama_cpp_launcher.services.health import fetch_first_model_id, wait_for_health
|
|
17
|
+
from opencode_llama_cpp_launcher.services.launch_preparer import LaunchPreparer
|
|
18
|
+
from opencode_llama_cpp_launcher.services.opencode_config import (
|
|
19
|
+
build_opencode_config,
|
|
20
|
+
dumps_opencode_config,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
StatusReporter = Callable[[LaunchStatus], None]
|
|
25
|
+
LLAMA_SERVER_LOG_TAIL_BYTES = 4000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_reporter(status: LaunchStatus) -> None:
|
|
29
|
+
print(status.value, flush=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Launcher:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
report_status: StatusReporter = default_reporter,
|
|
36
|
+
launch_preparer: LaunchPreparer | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._report_status = report_status
|
|
39
|
+
self._launch_preparer = launch_preparer or LaunchPreparer()
|
|
40
|
+
|
|
41
|
+
def run(self, config: LaunchConfig) -> int:
|
|
42
|
+
built_config = self._build_config(config)
|
|
43
|
+
|
|
44
|
+
if config.dry_run:
|
|
45
|
+
self._print_dry_run(built_config)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
with tempfile.TemporaryFile("w+b") as llama_server_log:
|
|
49
|
+
llama_process = self._start_llama_server(built_config, llama_server_log)
|
|
50
|
+
try:
|
|
51
|
+
try:
|
|
52
|
+
self._wait_for_llama_server(
|
|
53
|
+
built_config.llama_server.root_url,
|
|
54
|
+
llama_process,
|
|
55
|
+
)
|
|
56
|
+
except LauncherError as exc:
|
|
57
|
+
self._raise_with_llama_server_log(exc, llama_server_log)
|
|
58
|
+
|
|
59
|
+
# The real model id is only available after llama-server starts.
|
|
60
|
+
opencode_config = self._detect_opencode_config(
|
|
61
|
+
built_config.llama_server.base_url,
|
|
62
|
+
)
|
|
63
|
+
opencode_process = self._start_opencode(
|
|
64
|
+
built_config,
|
|
65
|
+
opencode_config,
|
|
66
|
+
)
|
|
67
|
+
return int(opencode_process.wait())
|
|
68
|
+
finally:
|
|
69
|
+
# OpenCode owns the interactive session; this launcher owns cleanup
|
|
70
|
+
# for the llama-server process it started.
|
|
71
|
+
self._terminate_process(llama_process)
|
|
72
|
+
|
|
73
|
+
def _build_config(self, config: LaunchConfig) -> BuiltLaunchConfig:
|
|
74
|
+
self._report_status(LaunchStatus.CHECKING_PREREQUISITES)
|
|
75
|
+
return self._launch_preparer.prepare(config)
|
|
76
|
+
|
|
77
|
+
def _start_llama_server(
|
|
78
|
+
self,
|
|
79
|
+
config: BuiltLaunchConfig,
|
|
80
|
+
log_file: BinaryIO,
|
|
81
|
+
) -> subprocess.Popen:
|
|
82
|
+
self._report_status(LaunchStatus.STARTING_LLAMA_SERVER)
|
|
83
|
+
# Keep llama-server isolated from OpenCode's terminal UI and make the
|
|
84
|
+
# whole llama.cpp process group easy to stop when OpenCode exits.
|
|
85
|
+
popen_kwargs = {
|
|
86
|
+
"stdin": subprocess.DEVNULL,
|
|
87
|
+
"stdout": log_file,
|
|
88
|
+
"stderr": subprocess.STDOUT,
|
|
89
|
+
}
|
|
90
|
+
if hasattr(os, "setsid"):
|
|
91
|
+
popen_kwargs["start_new_session"] = True
|
|
92
|
+
|
|
93
|
+
return subprocess.Popen(config.llama_server.command, **popen_kwargs)
|
|
94
|
+
|
|
95
|
+
def _wait_for_llama_server(
|
|
96
|
+
self,
|
|
97
|
+
root_url: str,
|
|
98
|
+
process: subprocess.Popen,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._report_status(LaunchStatus.WAITING_FOR_LLAMA_SERVER)
|
|
101
|
+
wait_for_health(
|
|
102
|
+
root_url,
|
|
103
|
+
is_server_running=lambda: process.poll() is None,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _detect_opencode_config(self, base_url: str) -> dict:
|
|
107
|
+
self._report_status(LaunchStatus.DETECTING_MODEL)
|
|
108
|
+
model_id = fetch_first_model_id(base_url)
|
|
109
|
+
return build_opencode_config(base_url, model_id)
|
|
110
|
+
|
|
111
|
+
def _start_opencode(
|
|
112
|
+
self,
|
|
113
|
+
built_config: BuiltLaunchConfig,
|
|
114
|
+
opencode_config: dict,
|
|
115
|
+
) -> subprocess.Popen:
|
|
116
|
+
self._report_status(LaunchStatus.STARTING_OPENCODE)
|
|
117
|
+
return subprocess.Popen(
|
|
118
|
+
built_config.opencode.command,
|
|
119
|
+
cwd=built_config.project,
|
|
120
|
+
env=self._opencode_env(opencode_config),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _opencode_env(self, opencode_config: dict) -> dict[str, str]:
|
|
124
|
+
env = os.environ.copy()
|
|
125
|
+
env["OPENCODE_CONFIG_CONTENT"] = dumps_opencode_config(opencode_config)
|
|
126
|
+
return env
|
|
127
|
+
|
|
128
|
+
def _print_dry_run(self, config: BuiltLaunchConfig) -> None:
|
|
129
|
+
print(f"Project: {config.project}")
|
|
130
|
+
print(f"Model: {config.llama_server.model_path}")
|
|
131
|
+
print(f"Selected port: {config.llama_server.selected_port}")
|
|
132
|
+
print("llama-server command:")
|
|
133
|
+
print(" ".join(config.llama_server.command))
|
|
134
|
+
print("opencode command:")
|
|
135
|
+
print(" ".join(config.opencode.command))
|
|
136
|
+
print("OPENCODE_CONFIG_CONTENT:")
|
|
137
|
+
print(dumps_opencode_config(config.opencode.config))
|
|
138
|
+
|
|
139
|
+
def _raise_with_llama_server_log(
|
|
140
|
+
self,
|
|
141
|
+
exc: LauncherError,
|
|
142
|
+
log_file: BinaryIO,
|
|
143
|
+
) -> None:
|
|
144
|
+
log_excerpt = self._llama_server_log_excerpt(log_file)
|
|
145
|
+
if not log_excerpt:
|
|
146
|
+
raise exc
|
|
147
|
+
|
|
148
|
+
raise LauncherError(f"{exc}\n\nllama-server output:\n{log_excerpt}") from exc
|
|
149
|
+
|
|
150
|
+
def _llama_server_log_excerpt(self, log_file: BinaryIO) -> str:
|
|
151
|
+
log_file.flush()
|
|
152
|
+
log_file.seek(0, os.SEEK_END)
|
|
153
|
+
size = log_file.tell()
|
|
154
|
+
if size == 0:
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
offset = max(0, size - LLAMA_SERVER_LOG_TAIL_BYTES)
|
|
158
|
+
log_file.seek(offset)
|
|
159
|
+
excerpt = log_file.read().decode("utf-8", errors="replace")
|
|
160
|
+
if offset > 0:
|
|
161
|
+
return f"[last {LLAMA_SERVER_LOG_TAIL_BYTES} bytes]\n{excerpt}"
|
|
162
|
+
return excerpt
|
|
163
|
+
|
|
164
|
+
def _terminate_process(self, process: subprocess.Popen) -> None:
|
|
165
|
+
if process.poll() is not None:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
self._terminate_process_group(process)
|
|
169
|
+
try:
|
|
170
|
+
process.wait(timeout=5)
|
|
171
|
+
except subprocess.TimeoutExpired:
|
|
172
|
+
self._kill_process_group(process)
|
|
173
|
+
process.wait()
|
|
174
|
+
|
|
175
|
+
def _terminate_process_group(self, process: subprocess.Popen) -> None:
|
|
176
|
+
if not hasattr(os, "killpg"):
|
|
177
|
+
process.terminate()
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
182
|
+
except ProcessLookupError:
|
|
183
|
+
return
|
|
184
|
+
except OSError:
|
|
185
|
+
process.terminate()
|
|
186
|
+
|
|
187
|
+
def _kill_process_group(self, process: subprocess.Popen) -> None:
|
|
188
|
+
if not hasattr(os, "killpg"):
|
|
189
|
+
process.kill()
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
194
|
+
except ProcessLookupError:
|
|
195
|
+
return
|
|
196
|
+
except OSError:
|
|
197
|
+
process.kill()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PROVIDER_ID = "llama.cpp"
|
|
7
|
+
PROVIDER_PACKAGE = "@ai-sdk/openai-compatible"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_opencode_config(base_url: str, model_id: str) -> dict:
|
|
11
|
+
selected_model = f"{PROVIDER_ID}/{model_id}"
|
|
12
|
+
return {
|
|
13
|
+
"$schema": "https://opencode.ai/config.json",
|
|
14
|
+
"model": selected_model,
|
|
15
|
+
"small_model": selected_model,
|
|
16
|
+
"provider": {
|
|
17
|
+
PROVIDER_ID: {
|
|
18
|
+
"npm": PROVIDER_PACKAGE,
|
|
19
|
+
"name": "llama-server (local)",
|
|
20
|
+
"options": {
|
|
21
|
+
"baseURL": base_url,
|
|
22
|
+
},
|
|
23
|
+
"models": {
|
|
24
|
+
model_id: {
|
|
25
|
+
"name": f"{model_id} (local)",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dumps_opencode_config(config: dict) -> str:
|
|
34
|
+
return json.dumps(config, separators=(",", ":"))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def select_port(host: str, preferred_port: int) -> int:
|
|
7
|
+
if is_port_available(host, preferred_port):
|
|
8
|
+
return preferred_port
|
|
9
|
+
|
|
10
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
11
|
+
sock.bind((host, 0))
|
|
12
|
+
return int(sock.getsockname()[1])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_port_available(host: str, port: int) -> bool:
|
|
16
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
17
|
+
sock.settimeout(0.2)
|
|
18
|
+
return sock.connect_ex((host, port)) != 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Config loading and storage helpers."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from opencode_llama_cpp_launcher.models.launch_config import (
|
|
11
|
+
DEFAULT_CTX_SIZE,
|
|
12
|
+
DEFAULT_PORT,
|
|
13
|
+
MAX_PORT,
|
|
14
|
+
)
|
|
15
|
+
from opencode_llama_cpp_launcher.services.errors import LauncherError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_CONFIG_NAMES = (
|
|
19
|
+
"opencode-llama.yaml",
|
|
20
|
+
"opencode-llama.yml",
|
|
21
|
+
".opencode-llama.yaml",
|
|
22
|
+
".opencode-llama.yml",
|
|
23
|
+
)
|
|
24
|
+
XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
|
|
25
|
+
USER_CONFIG_NAMES = ("opencode-llama.yaml", ".opencode-llama.yaml")
|
|
26
|
+
|
|
27
|
+
CONFIG_TEMPLATE = """# opencode-llama.yaml or ~/.config/opencode-llama.yaml
|
|
28
|
+
model: /absolute/path/to/model.gguf
|
|
29
|
+
llama_server: /optional/path/to/llama-server
|
|
30
|
+
port: 8080
|
|
31
|
+
ctx_size: 8192
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FileConfig:
|
|
37
|
+
path: Path | None
|
|
38
|
+
model: Path | None = None
|
|
39
|
+
llama_server_path: Path | None = None
|
|
40
|
+
port: int = DEFAULT_PORT
|
|
41
|
+
ctx_size: int = DEFAULT_CTX_SIZE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_config_path(project: Path, explicit_config: Path | None) -> Path | None:
|
|
45
|
+
if explicit_config is not None:
|
|
46
|
+
return explicit_config.expanduser()
|
|
47
|
+
|
|
48
|
+
for config_name in DEFAULT_CONFIG_NAMES:
|
|
49
|
+
candidate = project / config_name
|
|
50
|
+
if candidate.exists():
|
|
51
|
+
return candidate
|
|
52
|
+
|
|
53
|
+
for config_path in get_user_config_paths():
|
|
54
|
+
if config_path.exists():
|
|
55
|
+
return config_path
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_user_config_paths() -> tuple[Path, Path]:
|
|
61
|
+
config_home = os.environ.get(XDG_CONFIG_HOME_ENV)
|
|
62
|
+
if config_home:
|
|
63
|
+
config_dir = Path(config_home).expanduser()
|
|
64
|
+
else:
|
|
65
|
+
config_dir = Path.home() / ".config"
|
|
66
|
+
|
|
67
|
+
preferred_config_name, dotted_config_name = USER_CONFIG_NAMES
|
|
68
|
+
return (
|
|
69
|
+
config_dir / preferred_config_name,
|
|
70
|
+
config_dir / dotted_config_name,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_file_config(project: Path, explicit_config: Path | None = None) -> FileConfig:
|
|
75
|
+
config_path = find_config_path(project, explicit_config)
|
|
76
|
+
if config_path is None:
|
|
77
|
+
return FileConfig(path=None)
|
|
78
|
+
|
|
79
|
+
if not config_path.exists():
|
|
80
|
+
raise LauncherError(f"Config file does not exist: {config_path}")
|
|
81
|
+
|
|
82
|
+
raw_config = _load_yaml_mapping(config_path)
|
|
83
|
+
return FileConfig(
|
|
84
|
+
path=config_path,
|
|
85
|
+
model=_path_value(raw_config.get("model"), config_path.parent, "model"),
|
|
86
|
+
llama_server_path=_path_value(
|
|
87
|
+
raw_config.get("llama_server"),
|
|
88
|
+
config_path.parent,
|
|
89
|
+
"llama_server",
|
|
90
|
+
),
|
|
91
|
+
port=_int_value(raw_config.get("port"), DEFAULT_PORT, "port"),
|
|
92
|
+
ctx_size=_int_value(raw_config.get("ctx_size"), DEFAULT_CTX_SIZE, "ctx_size"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_yaml_mapping(config_path: Path) -> dict[str, Any]:
|
|
97
|
+
try:
|
|
98
|
+
raw_config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
99
|
+
except yaml.YAMLError as exc:
|
|
100
|
+
raise LauncherError(f"Could not parse YAML config {config_path}: {exc}") from exc
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
raise LauncherError(f"Could not read config {config_path}: {exc}") from exc
|
|
103
|
+
|
|
104
|
+
if raw_config is None:
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
if not isinstance(raw_config, dict):
|
|
108
|
+
raise LauncherError(f"Config file must contain a YAML mapping: {config_path}")
|
|
109
|
+
|
|
110
|
+
return raw_config
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _path_value(value: Any, base_dir: Path, key: str) -> Path | None:
|
|
114
|
+
if value is None:
|
|
115
|
+
return None
|
|
116
|
+
if not isinstance(value, str):
|
|
117
|
+
raise LauncherError(f"Config value `{key}` must be a string path.")
|
|
118
|
+
|
|
119
|
+
path = Path(value).expanduser()
|
|
120
|
+
if not path.is_absolute():
|
|
121
|
+
path = base_dir / path
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _int_value(value: Any, default: int, key: str) -> int:
|
|
126
|
+
if value is None:
|
|
127
|
+
return default
|
|
128
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
129
|
+
raise LauncherError(f"Config value `{key}` must be an integer.")
|
|
130
|
+
if value <= 0:
|
|
131
|
+
raise LauncherError(f"Config value `{key}` must be greater than zero.")
|
|
132
|
+
if key == "port" and value > MAX_PORT:
|
|
133
|
+
raise LauncherError(f"Config value `{key}` must be at most {MAX_PORT}.")
|
|
134
|
+
return value
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencode-llama-cpp-launcher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One command launcher for running OpenCode with a local llama.cpp model.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: agentic-coding,cli,llama-cpp,local-llm,opencode
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
20
|
+
Requires-Dist: typer>=0.12.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# OpenCode llama.cpp Launcher
|
|
24
|
+
|
|
25
|
+
A one command solution for launching [OpenCode](https://opencode.ai/) with any
|
|
26
|
+
local LLM that `llama-server` can serve, including models like Qwen, DeepSeek,
|
|
27
|
+
and Gemma. This launcher starts `llama-server`, waits for it to become ready,
|
|
28
|
+
wires the OpenAI compatible provider config into OpenCode, and cleans up when
|
|
29
|
+
the local agentic coding session ends.
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Python 3.12+
|
|
34
|
+
- OpenCode
|
|
35
|
+
- llama.cpp's `llama-server`
|
|
36
|
+
- A local model supported by `llama-server`, for example Qwen, DeepSeek, or
|
|
37
|
+
Gemma
|
|
38
|
+
|
|
39
|
+
The launcher finds `llama-server` on `PATH`, or you can set `llama_server` in
|
|
40
|
+
your config.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
From this repository:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv sync --dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Check that the required external binaries are available:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run opencode-llama doctor
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configure
|
|
57
|
+
|
|
58
|
+
Create a project-local config in the project where you want OpenCode to run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cp opencode-llama.example.yaml opencode-llama.yaml
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then edit `opencode-llama.yaml`:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
model: /absolute/path/to/model.gguf
|
|
68
|
+
llama_server: /optional/path/to/llama-server
|
|
69
|
+
port: 8080
|
|
70
|
+
ctx_size: 8192
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Config lookup order:
|
|
74
|
+
|
|
75
|
+
1. The path passed with `--config`
|
|
76
|
+
2. `opencode-llama.yaml` or `opencode-llama.yml` in the project directory
|
|
77
|
+
3. `~/.config/opencode-llama.yaml`
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
Run with an explicit config file:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv run opencode-llama --config opencode-llama.yaml
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or pass the model directly:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv run opencode-llama --model /absolute/path/to/model.gguf
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Useful options:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv run opencode-llama --help
|
|
97
|
+
uv run opencode-llama --dry-run
|
|
98
|
+
uv run opencode-llama --config opencode-llama.yaml
|
|
99
|
+
uv run opencode-llama --port 9001
|
|
100
|
+
uv run opencode-llama --ctx-size 8192
|
|
101
|
+
uv run opencode-llama --llama-server /absolute/path/to/llama-server
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
If `llama-server` fails before becoming healthy, the launcher includes a bounded
|
|
105
|
+
tail of the server's startup output in the error message. Successful runs stay
|
|
106
|
+
quiet.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
Run the test suite:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run pytest
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Before publishing, check for local files:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git status --short --ignored
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Do not commit local launcher configs, virtual environments, caches, build
|
|
123
|
+
artifacts, or model paths.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
opencode_llama_cpp_launcher/__init__.py,sha256=PCZOkVwXXSr3RYPNZdZz-2Qo-6vATkk68dDCW0FJeiI,66
|
|
2
|
+
opencode_llama_cpp_launcher/cli/__init__.py,sha256=p3sRYNlgLcYI08JgetCyqf1wpgvZlRBdz_R6MbxU_cI,23
|
|
3
|
+
opencode_llama_cpp_launcher/cli/entrypoint.py,sha256=OkKMhTXg1mp2AxnwwM2H0GNy739RewgnPXAHb_MwK9U,2963
|
|
4
|
+
opencode_llama_cpp_launcher/models/__init__.py,sha256=7Xx9MxZknT4c4QQr3F5fKDlB2hN3F2lFHqseJnlvY88,57
|
|
5
|
+
opencode_llama_cpp_launcher/models/launch_config.py,sha256=r0yzTKkeRfGWVHcDXMopxGtOlr_U0FZ9wzjXFPixqaM,809
|
|
6
|
+
opencode_llama_cpp_launcher/models/launch_status.py,sha256=iLcX_ZVWY4olIo4MFOKadBqipOcHyHt-WMmJdx0RPvc,398
|
|
7
|
+
opencode_llama_cpp_launcher/services/__init__.py,sha256=f_O4iWCTQ1ndiuaiV4uVsEu4GGx9em0gZEWo9rB9_K8,25
|
|
8
|
+
opencode_llama_cpp_launcher/services/binaries.py,sha256=Du14Wp0wGUep86HvS8x6_aqwxYCrRx6XRSCGrVJtsvM,313
|
|
9
|
+
opencode_llama_cpp_launcher/services/errors.py,sha256=Zrey_carJ8Rk6eo7nJbNnm7FxJW6vDORe5nN02XomKE,121
|
|
10
|
+
opencode_llama_cpp_launcher/services/health.py,sha256=gQSF9p8SzIKoEGev9P0VcsM8ampPz67fATKQVziD9Ng,2200
|
|
11
|
+
opencode_llama_cpp_launcher/services/launch_config_loader.py,sha256=PJzFO_SW2EbXW7UcYvupvwB5laxz9sVg-x-3qXGPWVU,3591
|
|
12
|
+
opencode_llama_cpp_launcher/services/launch_preparer.py,sha256=HZCfZalSEZYYBg2dM8hesTjcKfAGye7f6s1ERufMx7k,4361
|
|
13
|
+
opencode_llama_cpp_launcher/services/launcher.py,sha256=XpvRfYrxJ3346J_2gyqRXkextJTxuT_y7SUaLNHZKfM,6783
|
|
14
|
+
opencode_llama_cpp_launcher/services/opencode_config.py,sha256=OzT9V5PzGb9jxnzTBgsv_zzJnI2QzUUaaCkOrjcGtLk,897
|
|
15
|
+
opencode_llama_cpp_launcher/services/ports.py,sha256=1OgE3IqZF9mpmyvBoiuV61O3aSxDlkWcs4nfE5ehIOg,528
|
|
16
|
+
opencode_llama_cpp_launcher/storage/__init__.py,sha256=qNn6blTqWXGEfHe2fb5H2Jl7qCLsE7aymNYohcyc_Q4,42
|
|
17
|
+
opencode_llama_cpp_launcher/storage/config_loader.py,sha256=e-RhLMI8TcWOgOEEtmbfhcId8LZtUWcNRSwsyJM17Pc,4039
|
|
18
|
+
opencode_llama_cpp_launcher-0.1.0.dist-info/METADATA,sha256=eNgIVVdJdckY8Oyzyz0eXUf-OPuiDFhu5isR9afL5oI,3067
|
|
19
|
+
opencode_llama_cpp_launcher-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
20
|
+
opencode_llama_cpp_launcher-0.1.0.dist-info/entry_points.txt,sha256=lvH_XU3XuLgcZZOCBzu6yUMIho_zkAHgGW7e-PcCyo0,83
|
|
21
|
+
opencode_llama_cpp_launcher-0.1.0.dist-info/licenses/LICENSE,sha256=sFB7n5aEp0gwYBaZP1GsXm06-wvT2LHAVVXShPOTqW8,1063
|
|
22
|
+
opencode_llama_cpp_launcher-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ribomo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|