diff --git a/README.md b/README.md
index 33c9393..4d33d83 100644
--- a/README.md
+++ b/README.md
@@ -132,6 +132,15 @@ sudo bash deploy/install-client.sh --server http://your-server:5050 --token "cha
Options: `--server`, `--token` (required); `--client-id`, `--upstream`, `--install-dir`, `--service-name`, `--no-ollama` (optional). Missing required values are prompted interactively.
+llama.cpp via Docker options (replaces Ollama for inferencing):
+
+| Option | Description |
+|--------|-------------|
+| `--use-llama-cpp-via-docker` | Use llama.cpp Docker containers instead of Ollama |
+| `--use-ollama-models-path
` | Path to Ollama models directory (`manifests/blobs`); required with the flag above |
+| `--llama-cpp-docker-image
` | Docker image; defaults to auto-detected (rocm/cuda/cpu) |
+| `--llama-cpp-base-port ` | Base port for containers; defaults to `8081` |
+
The script ensures .NET 10 and Ollama are installed, builds the client self-contained, installs it to `/opt/Ngino-client`, and creates a systemd service (`Ngino-client`). Logs: `journalctl -u Ngino-client -f`.
## Notes
diff --git a/deploy/install-client.sh b/deploy/install-client.sh
index 23f757b..dd5d7ec 100755
--- a/deploy/install-client.sh
+++ b/deploy/install-client.sh
@@ -279,6 +279,14 @@ fi
info "Build successful."
+# ── Stop existing service before overwriting binary ─────────────────────────
+SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
+
+if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1 || systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
+ info "Stopping existing service $SERVICE_NAME..."
+ systemctl stop "$SERVICE_NAME" 2>/dev/null || true
+fi
+
# ── Install ───────────────────────────────────────────────────────────────────
info "Installing to $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR"
@@ -311,11 +319,6 @@ info "Environment file written to $ENV_DIR/env (mode 0600)."
# ── Create systemd service ───────────────────────────────────────────────────
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
-if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1; then
- info "Stopping existing service $SERVICE_NAME..."
- systemctl stop "$SERVICE_NAME" 2>/dev/null || true
-fi
-
cat > "$SERVICE_FILE" < FindExistingContainerPortAsync(string containerName)
+ {
+ var (exitCode, output) = await RunDockerWithOutputAsync(
+ ["ps", "--filter", $"name=^{containerName}$", "--format", "{{.Ports}}"],
+ CancellationToken.None);
+
+ if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
+ {
+ return null;
+ }
+
+ foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var port = ParseHostPort(line);
+ if (port.HasValue)
+ {
+ return port;
+ }
+ }
+
+ return null;
+ }
+
+ private static int? ParseHostPort(string ports)
+ {
+ foreach (var mapping in ports.Split(','))
+ {
+ var trimmed = mapping.Trim();
+ var arrowIndex = trimmed.IndexOf("->", StringComparison.Ordinal);
+ if (arrowIndex < 0)
+ {
+ continue;
+ }
+
+ var hostPart = trimmed[..arrowIndex].Trim();
+ var colonIndex = hostPart.LastIndexOf(':');
+ if (colonIndex < 0)
+ {
+ continue;
+ }
+
+ if (int.TryParse(hostPart[(colonIndex + 1)..], out var port))
+ {
+ return port;
+ }
+ }
+
+ return null;
+ }
+
private int FindAvailablePort()
{
var usedPorts = new HashSet(_modelPorts.Values);
@@ -393,10 +455,9 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable
var readOutput = process.StandardOutput.ReadToEndAsync(cancellationToken);
var readError = process.StandardError.ReadToEndAsync(cancellationToken);
+ var waitTask = process.WaitForExitAsync(cancellationToken);
- var completed = await Task.WhenAny(
- process.WaitForExitAsync(cancellationToken),
- Task.Delay(DockerTimeout, cancellationToken));
+ var completed = await Task.WhenAny(waitTask, Task.Delay(DockerTimeout, cancellationToken));
string output;
string error;
@@ -412,8 +473,7 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable
error = "timed out";
}
- if (completed is Task { IsCompleted: true } delayTask
- && delayTask != process.WaitForExitAsync(cancellationToken))
+ if (completed != waitTask)
{
_logger.LogWarning("Docker command timed out: docker {Args}", string.Join(" ", args));
try { process.Kill(entireProcessTree: true); } catch { }
@@ -443,7 +503,7 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable
return "ghcr.io/ggml-org/llama.cpp:server";
}
- private static bool HasRocmDevices() => File.Exists("/dev/kfd") && File.Exists("/dev/dri");
+ private static bool HasRocmDevices() => File.Exists("/dev/kfd") && Directory.Exists("/dev/dri");
private static bool HasNvidiaGpu()
{
diff --git a/src/Ngino.Client/OllamaToLlamaCppTranslator.cs b/src/Ngino.Client/OllamaToLlamaCppTranslator.cs
new file mode 100644
index 0000000..0950e2e
--- /dev/null
+++ b/src/Ngino.Client/OllamaToLlamaCppTranslator.cs
@@ -0,0 +1,605 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Ngino.Protocol;
+
+namespace Ngino.Client;
+
+internal sealed class OllamaToLlamaCppTranslator
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly string _modelName;
+ private readonly ILogger _logger;
+
+ public OllamaToLlamaCppTranslator(string modelName, ILogger logger)
+ {
+ _modelName = modelName;
+ _logger = logger;
+ }
+
+ public bool TryTranslatePath(string method, string pathAndQuery, out string newPath)
+ {
+ var path = pathAndQuery.Split('?')[0];
+ newPath = path switch
+ {
+ "/api/generate" => "/completion",
+ "/api/chat" => "/v1/chat/completions",
+ "/api/embed" or "/api/embeddings" => "/v1/embeddings",
+ _ => null!
+ };
+ return newPath is not null;
+ }
+
+ public byte[] TranslateBody(string pathAndQuery, byte[] body)
+ {
+ if (body is null || body.Length == 0)
+ return body;
+
+ var path = pathAndQuery.Split('?')[0];
+
+ try
+ {
+ return path switch
+ {
+ "/api/generate" => TranslateGenerateBody(body),
+ "/api/chat" => TranslateChatBody(body),
+ "/api/embed" or "/api/embeddings" => TranslateEmbedBody(body),
+ _ => body
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to translate request body for {Path}", path);
+ return body;
+ }
+ }
+
+ private byte[] TranslateGenerateBody(byte[] body)
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+
+ var result = new Dictionary();
+
+ if (root.TryGetProperty("prompt", out var prompt))
+ result["prompt"] = prompt.GetString() ?? "";
+
+ result["stream"] = true;
+
+ CopyOptions(root, result);
+
+ if (!result.ContainsKey("n_predict"))
+ result["n_predict"] = 2048;
+
+ return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
+ }
+
+ private byte[] TranslateChatBody(byte[] body)
+ {
+ using var doc = JsonDocument.Parse(body);
+ var root = doc.RootElement;
+
+ var result = new Dictionary();
+
+ if (root.TryGetProperty("messages", out var messages))
+ result["messages"] = messages.Deserialize