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.
@@ -0,0 +1,3 @@
1
+ """OpenCode llama.cpp launcher package."""
2
+
3
+ __version__ = "0.1.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,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class LauncherError(RuntimeError):
5
+ """Expected user-facing launcher failure."""
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opencode-llama = opencode_llama_cpp_launcher.cli.entrypoint:main
@@ -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.