refactor(mx): drive opencode bot via direct chat-completions API

The bot no longer shells out to `opencode run`. Instead it POSTs to the
OpenAI-compatible /chat/completions endpoint exposed by llama-server on
halo.hoyer.tail:8000 directly. This removes the Bun/sqlite cold-start
overhead per request, drops the pkgs.opencode runtime dependency, and
eliminates the ExecStartPre dance that materialized config.json into the
service's $HOME.

Conversation history is now stored as a proper OpenAI `messages` list
with system/user/assistant roles, instead of the XML blob that was
inlined into a single `opencode run` argument. The interactive opencode
setup (config/opencode/config.json) is unchanged — only the bot stops
depending on it.

The module gains a `modelBaseUrl` option; `model` is now the bare model
name (`halo-8000`) without the provider/ prefix that the opencode CLI
required.
This commit is contained in:
Harald Hoyer 2026-05-13 16:38:58 +02:00
parent aa3bc3c457
commit 42c52bd87f
3 changed files with 72 additions and 101 deletions

View file

@ -51,21 +51,16 @@ in
description = "Path to file containing the bot secret (shared with Nextcloud)";
};
opencodePath = mkOption {
type = types.path;
default = "${pkgs.opencode}/bin/opencode";
description = "Path to opencode CLI binary";
};
opencodeConfig = mkOption {
type = types.path;
description = "Path to the opencode config.json file (placed at $HOME/.config/opencode/config.json on service start)";
modelBaseUrl = mkOption {
type = types.str;
example = "http://halo.hoyer.tail:8000/v1";
description = "Base URL of the OpenAI-compatible chat-completions endpoint (without trailing /chat/completions)";
};
model = mkOption {
type = types.str;
default = "halo-8000/halo-8000";
description = "Model identifier passed to `opencode run -m`";
default = "halo-8000";
description = "Model name passed in the `model` field of /chat/completions requests";
};
botName = mkOption {
@ -83,13 +78,13 @@ in
contextMessages = mkOption {
type = types.int;
default = 6;
description = "Number of recent messages to keep as context";
description = "Number of recent (user+assistant) turns to keep as context";
};
timeout = mkOption {
type = types.int;
default = 120;
description = "Timeout in seconds for opencode CLI";
description = "Timeout in seconds for the model API call";
};
systemPrompt = mkOption {
@ -105,21 +100,12 @@ in
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
path = with pkgs; [
bash
coreutils
git
curl
jq
];
environment = {
HOME = "/var/lib/nextcloud-opencode-bot";
BOT_HOST = cfg.host;
BOT_PORT = toString cfg.port;
NEXTCLOUD_URL = cfg.nextcloudUrl;
OPENCODE_PATH = cfg.opencodePath;
OPENCODE_MODEL = cfg.model;
MODEL_BASE_URL = cfg.modelBaseUrl;
MODEL_NAME = cfg.model;
BOT_NAME = cfg.botName;
ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers;
CONTEXT_MESSAGES = toString cfg.contextMessages;
@ -131,15 +117,6 @@ in
serviceConfig = {
Type = "simple";
# Materialize the opencode config at the path opencode looks for by
# default ($HOME/.config/opencode/config.json). We copy rather than
# symlink so opencode's config loader sees a regular file.
ExecStartPre = pkgs.writeShellScript "install-opencode-config" ''
set -eu
install -d -m 0700 "$HOME/.config/opencode"
install -m 0600 ${cfg.opencodeConfig} "$HOME/.config/opencode/config.json"
'';
ExecStart = "${pythonEnv}/bin/uvicorn nextcloud_opencode_bot:app --host ${cfg.host} --port ${toString cfg.port}";
Restart = "always";
RestartSec = 5;
@ -170,7 +147,6 @@ in
users.users.opencode-bot = {
isSystemUser = true;
group = "opencode-bot";
home = "/var/lib/nextcloud-opencode-bot";
};
users.groups.opencode-bot = { };