From b849cfd4b789522cac0a21da06c759321b47e2a1 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Sun, 2 Aug 2026 01:24:09 +0200 Subject: [PATCH] feat(client): file logging + llama.cpp fallback cooldown and parallel options --- README.md | 13 +- deploy/install-client.sh | 23 ++- src/Ngino.Client/ClientOptions.cs | 21 +- src/Ngino.Client/FileLogger.cs | 113 +++++++++++ src/Ngino.Client/LlamaCppManager.cs | 288 +++++++++++++++++++++++----- src/Ngino.Client/Program.cs | 8 + src/Ngino.Client/TunnelClient.cs | 112 +++++------ 7 files changed, 457 insertions(+), 121 deletions(-) create mode 100644 src/Ngino.Client/FileLogger.cs diff --git a/README.md b/README.md index d111c87..f451842 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,11 @@ sudo bash deploy/install-client.sh --server http://your-server:5050 --token "cha ``` or using ollama models with llama.cpp backend using ROCm: ```bash -sudo bash deploy/install-client.sh --server https://ai.domain.tld --token "change-me" --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models --llama-cpp-docker-image ghcr.io/ggml-org/llama.cpp:server-rocm --llama-cpp-base-port 8081 +sudo bash deploy/install-client.sh --server https://ai.domain.tld --token "change-me" --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models --llama-cpp-docker-image ghcr.io/ggml-org/llama.cpp:server-rocm --llama-cpp-base-port 8081 --llama-cpp-parallel 128 ``` or using ollama models with llama.cpp backend using CUDA: ```bash -sudo bash deploy/install-client.sh --server https://ai.domain.tld --token "change-me" --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models --llama-cpp-docker-image ghcr.io/ggml-org/llama.cpp:server-cuda --llama-cpp-base-port 8081 +sudo bash deploy/install-client.sh --server https://ai.domain.tld --token "change-me" --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models --llama-cpp-docker-image ghcr.io/ggml-org/llama.cpp:server-cuda --llama-cpp-base-port 8081 --llama-cpp-parallel 128 ``` Options: `--server`, `--token` (required); `--client-id`, `--upstream`, `--install-dir`, `--service-name`, `--no-ollama` (optional). Missing required values are prompted interactively. @@ -150,6 +150,15 @@ llama.cpp via Docker options (replaces Ollama for inferencing): | `--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` | +| `--llama-cpp-parallel ` | llama.cpp parallel slots per container; if unset, llama.cpp's own default is used (which is `1`) | +| `--llama-cpp-fallback-cooldown ` | Seconds before llama.cpp is retried after a failed container start; defaults to `180` | +| `--log-dir ` | Directory for log files; defaults to `/Logs` | + +### llama.cpp fallback to Ollama + +Models are served via llama.cpp Docker containers. If a container cannot be started for a model (for example, the model's GGUF blob is incompatible with the llama.cpp build), the client falls back to the Ollama upstream for that model. Transient start failures are remembered for `--llama-cpp-fallback-cooldown` seconds (default 3 minutes) and then retried; a container that starts but exits before becoming ready marks the model as falling back until it is unloaded. `load`/`unload` model commands and on-demand request routing are all covered; a failed container start is detected quickly by watching the container state, and the container log tail is written to the client log to aid debugging. + +Note: some hybrid SSM/attention models (e.g. `qwen3.5-coder-next`) are converted by Ollama into a GGUF tensor layout that stock llama.cpp cannot load (`missing tensor 'blk.0.ssm_dt.bias'` and similar). Such models are served via the Ollama fallback above. If you want them to run on llama.cpp instead, use a Hugging Face-converted GGUF (e.g. `unsloth/Qwen3-Coder-Next-GGUF`) rather than the Ollama blob. 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`. diff --git a/deploy/install-client.sh b/deploy/install-client.sh index dd5d7ec..5b36772 100755 --- a/deploy/install-client.sh +++ b/deploy/install-client.sh @@ -20,6 +20,9 @@ USE_LLAMA_CPP_VIA_DOCKER=false USE_OLLAMA_MODELS_PATH="" LLAMA_CPP_DOCKER_IMAGE="" LLAMA_CPP_BASE_PORT="" +LLAMA_CPP_PARALLEL="" +LLAMA_CPP_FALLBACK_COOLDOWN="" +LOG_DIR="" # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m' @@ -57,13 +60,19 @@ Optional: llama.cpp Docker image; defaults to auto-detected (rocm/cuda/cpu) --llama-cpp-base-port Base port for llama.cpp containers; defaults to 8081 + --llama-cpp-parallel + llama.cpp parallel slots per container; if unset, llama.cpp's own default is used + --llama-cpp-fallback-cooldown + Seconds before llama.cpp is retried after a failed container start; defaults to 180 + --log-dir Directory for log files; defaults to /Logs -h, --help Show this help message Examples: $0 --server http://gpu-server:5050 --token "my-secret" $0 --server http://gpu-server:5050 --token "my-secret" --no-ollama $0 --server http://gpu-server:5050 --token "my-secret" \\ - --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models + --use-llama-cpp-via-docker --use-ollama-models-path /usr/share/ollama/.ollama/models \\ + --llama-cpp-parallel 128 EOF exit 0 } @@ -82,6 +91,9 @@ while [[ $# -gt 0 ]]; do --use-ollama-models-path) USE_OLLAMA_MODELS_PATH="$2"; shift 2 ;; --llama-cpp-docker-image) LLAMA_CPP_DOCKER_IMAGE="$2"; shift 2 ;; --llama-cpp-base-port) LLAMA_CPP_BASE_PORT="$2"; shift 2 ;; + --llama-cpp-parallel) LLAMA_CPP_PARALLEL="$2"; shift 2 ;; + --llama-cpp-fallback-cooldown) LLAMA_CPP_FALLBACK_COOLDOWN="$2"; shift 2 ;; + --log-dir) LOG_DIR="$2"; shift 2 ;; -h|--help) usage ;; *) die "Unknown option: $1" ;; esac @@ -311,6 +323,15 @@ mkdir -p "$ENV_DIR" if [[ -n "$LLAMA_CPP_BASE_PORT" ]]; then printf 'NGINO_LLAMA_CPP_BASE_PORT=%s\n' "$LLAMA_CPP_BASE_PORT" fi + if [[ -n "$LLAMA_CPP_PARALLEL" ]]; then + printf 'NGINO_LLAMA_CPP_PARALLEL=%s\n' "$LLAMA_CPP_PARALLEL" + fi + if [[ -n "$LLAMA_CPP_FALLBACK_COOLDOWN" ]]; then + printf 'NGINO_LLAMA_CPP_FALLBACK_COOLDOWN_SECONDS=%s\n' "$LLAMA_CPP_FALLBACK_COOLDOWN" + fi + fi + if [[ -n "$LOG_DIR" ]]; then + printf 'NGINO_LOG_DIR=%s\n' "$LOG_DIR" fi } > "$ENV_DIR/env" chmod 600 "$ENV_DIR/env" diff --git a/src/Ngino.Client/ClientOptions.cs b/src/Ngino.Client/ClientOptions.cs index 4a3256c..cab7b65 100644 --- a/src/Ngino.Client/ClientOptions.cs +++ b/src/Ngino.Client/ClientOptions.cs @@ -28,6 +28,12 @@ internal sealed class ClientOptions public int LlamaCppBasePort { get; init; } = 8081; + public int? LlamaCppParallel { get; init; } + + public TimeSpan LlamaCppFallbackCooldown { get; init; } = TimeSpan.FromMinutes(3); + + public string? LogDirectory { get; init; } + public Uri TunnelUri { get @@ -68,7 +74,10 @@ internal sealed class ClientOptions UseLlamaCppViaDocker = ReadBool(values, false, "use-llama-cpp-via-docker", "NGINO_USE_LLAMA_CPP_VIA_DOCKER"), UseOllamaModelsPath = NormalizeDirectoryPath(Read(values, "use-ollama-models-path", "NGINO_USE_OLLAMA_MODELS_PATH")), LlamaCppDockerImage = Read(values, "llama-cpp-docker-image", "NGINO_LLAMA_CPP_DOCKER_IMAGE"), - LlamaCppBasePort = ReadInt(values, 8081, "llama-cpp-base-port", "NGINO_LLAMA_CPP_BASE_PORT") + LlamaCppBasePort = ReadInt(values, 8081, "llama-cpp-base-port", "NGINO_LLAMA_CPP_BASE_PORT"), + LlamaCppParallel = ReadOptionalInt(values, "llama-cpp-parallel", "NGINO_LLAMA_CPP_PARALLEL"), + LlamaCppFallbackCooldown = TimeSpan.FromSeconds(ReadInt(values, 180, "llama-cpp-fallback-cooldown", "NGINO_LLAMA_CPP_FALLBACK_COOLDOWN_SECONDS")), + LogDirectory = NormalizeDirectoryPath(Read(values, "log-dir", "NGINO_LOG_DIR")) }; } @@ -87,6 +96,10 @@ internal sealed class ClientOptions --use-ollama-models-path Path to Ollama models directory (manifests/blobs), required with --use-llama-cpp-via-docker --llama-cpp-docker-image llama.cpp Docker image; defaults to auto-detected (rocm/cuda/cpu) --llama-cpp-base-port Base port for llama.cpp containers; defaults to 8081 + --llama-cpp-parallel llama.cpp parallel slots per container; if unset, llama.cpp's own default is used + --llama-cpp-fallback-cooldown + Seconds before llama.cpp is retried after a failed container start; defaults to 180 + --log-dir Directory for log files; defaults to /Logs """; private static Dictionary ParseArgs(string[] args) @@ -160,6 +173,12 @@ internal sealed class ClientOptions return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback; } + private static int? ReadOptionalInt(Dictionary values, params string[] keys) + { + var value = Read(values, keys); + return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : null; + } + private static bool ReadBool(Dictionary values, bool fallback, params string[] keys) { var value = Read(values, keys); diff --git a/src/Ngino.Client/FileLogger.cs b/src/Ngino.Client/FileLogger.cs new file mode 100644 index 0000000..9d42045 --- /dev/null +++ b/src/Ngino.Client/FileLogger.cs @@ -0,0 +1,113 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Ngino.Client; + +internal sealed class FileLoggerProvider : ILoggerProvider +{ + private const long DefaultMaxFileSizeBytes = 5L * 1024 * 1024; + private const string LogFileName = "ngino-client.log"; + private const string RotatedLogFileName = "ngino-client.log.1"; + + private readonly string _directory; + private readonly long _maxFileSizeBytes; + private readonly object _lock = new(); + private StreamWriter _writer = null!; + private string _currentFile = null!; + + public FileLoggerProvider(string directory, long maxFileSizeBytes = DefaultMaxFileSizeBytes) + { + _directory = directory; + _maxFileSizeBytes = maxFileSizeBytes; + Directory.CreateDirectory(directory); + OpenFile(); + } + + public string LogDirectory => _directory; + + public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName); + + public void WriteLog(DateTime timestamp, LogLevel level, string category, string message) + { + lock (_lock) + { + var line = $"{timestamp:yyyy-MM-dd HH:mm:ss.fff} [{level}] {category}: {message}"; + if (_writer.BaseStream.Length + line.Length + 2 > _maxFileSizeBytes) + { + RotateFile(); + } + + _writer.WriteLine(line); + } + } + + public void Dispose() + { + lock (_lock) + { + _writer.Dispose(); + } + } + + private void OpenFile() + { + _currentFile = Path.Combine(_directory, LogFileName); + _writer = new StreamWriter( + new FileStream(_currentFile, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true + }; + } + + private void RotateFile() + { + _writer.Dispose(); + + var rotatedFile = Path.Combine(_directory, RotatedLogFileName); + try + { + File.Delete(rotatedFile); + if (File.Exists(_currentFile)) + { + File.Move(_currentFile, rotatedFile); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + OpenFile(); + } +} + +internal sealed class FileLogger(FileLoggerProvider provider, string category) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + if (exception is not null) + { + message += Environment.NewLine + exception; + } + + provider.WriteLog(DateTime.Now, logLevel, category, message); + } +} diff --git a/src/Ngino.Client/LlamaCppManager.cs b/src/Ngino.Client/LlamaCppManager.cs index eccdbe3..d116ef4 100644 --- a/src/Ngino.Client/LlamaCppManager.cs +++ b/src/Ngino.Client/LlamaCppManager.cs @@ -16,24 +16,35 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable private const string NginoContainerLabel = "ngino-llamacpp"; private static readonly TimeSpan DockerTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan ContainerStartTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan DefaultFallbackCooldown = TimeSpan.FromMinutes(3); private readonly string _blobsPath; private readonly string _manifestsPath; private readonly string _dockerImage; private readonly int _basePort; + private readonly int? _parallel; + private readonly TimeSpan _fallbackCooldown; private readonly ILogger _logger; private readonly ConcurrentDictionary _modelPorts = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _reservedPorts = new(); + private readonly ConcurrentDictionary _fallbackModels = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _modelStartLocks = new(StringComparer.OrdinalIgnoreCase); + private readonly object _portAllocationLock = new(); public LlamaCppManager( string ollamaModelsPath, string? dockerImage, int? basePort, - ILogger? logger = null) + ILogger? logger = null, + TimeSpan fallbackCooldown = default, + int? parallel = null) { _manifestsPath = Path.Combine(ollamaModelsPath, "manifests"); _blobsPath = Path.Combine(ollamaModelsPath, "blobs"); _dockerImage = dockerImage ?? GetDefaultDockerImage(); _basePort = basePort ?? DefaultBasePort; + _fallbackCooldown = fallbackCooldown > TimeSpan.Zero ? fallbackCooldown : DefaultFallbackCooldown; + _parallel = parallel is > 0 ? parallel : null; _logger = logger ?? NullLogger.Instance; } @@ -78,6 +89,45 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable return _modelPorts.ContainsKey(ollamaModelName); } + public void MarkModelAsFallback(string ollamaModelName) + { + _fallbackModels[ollamaModelName] = DateTime.UtcNow.Add(_fallbackCooldown); + _logger.LogWarning( + "Model {Model} will fall back to the Ollama upstream for {Cooldown} before llama.cpp is retried.", + ollamaModelName, _fallbackCooldown); + } + + public void MarkModelAsPermanentFallback(string ollamaModelName) + { + _fallbackModels[ollamaModelName] = DateTime.MaxValue; + _logger.LogError( + "Model {Model} exited its llama.cpp container before becoming ready. It will fall back to the Ollama upstream until it is unloaded.", + ollamaModelName); + } + + public bool IsModelOnFallback(string ollamaModelName) + { + if (_fallbackModels.TryGetValue(ollamaModelName, out var expiresAt)) + { + if (expiresAt > DateTime.UtcNow) + { + return true; + } + + _fallbackModels.TryRemove(ollamaModelName, out _); + } + + return false; + } + + public void ClearModelFallback(string ollamaModelName) + { + if (_fallbackModels.TryRemove(ollamaModelName, out _)) + { + _logger.LogInformation("Cleared llama.cpp fallback marker for model {Model}.", ollamaModelName); + } + } + public async Task IsContainerRunningAsync(string ollamaModelName) { if (!_modelPorts.ContainsKey(ollamaModelName)) @@ -96,7 +146,7 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable _logger.LogWarning( "llama.cpp container {ContainerName} is no longer running. Invalidating cached port for {Model}.", containerName, ollamaModelName); - _modelPorts.TryRemove(ollamaModelName, out _); + RemoveModelPort(ollamaModelName); } return running; @@ -104,11 +154,11 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable public bool RemoveModelMapping(string ollamaModelName) { - if (_modelPorts.TryRemove(ollamaModelName, out var port)) + if (RemoveModelPort(ollamaModelName)) { _logger.LogWarning( - "Removed stale llama.cpp port mapping for {Model} (port {Port}).", - ollamaModelName, port); + "Removed stale llama.cpp port mapping for {Model}.", + ollamaModelName); return true; } @@ -134,45 +184,75 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable return false; } + var startLock = _modelStartLocks.GetOrAdd(ollamaName, static _ => new SemaphoreSlim(1, 1)); + await startLock.WaitAsync(cancellationToken); + try + { + return await StartModelContainerCoreAsync(model, cancellationToken); + } + finally + { + startLock.Release(); + } + } + + private async Task StartModelContainerCoreAsync(LlamaCppModel model, CancellationToken cancellationToken) + { + var ollamaName = model.OllamaName; + if (string.IsNullOrWhiteSpace(ollamaName)) + { + return false; + } + if (_modelPorts.ContainsKey(ollamaName)) { _logger.LogInformation("Model {Model} already has a running container", ollamaName); return true; } + if (IsModelOnFallback(ollamaName)) + { + _logger.LogInformation( + "Model {Model} previously failed to load via llama.cpp. Skipping container start.", + ollamaName); + return false; + } + if (!File.Exists(model.BlobPath)) { _logger.LogError("Model blob not found: {BlobPath}", model.BlobPath); + MarkModelAsFallback(ollamaName); return false; } var port = FindAvailablePort(); var containerName = SanitizeContainerName($"ngino-llamacpp-{ollamaName}"); - var existingPort = await FindExistingContainerPortAsync(containerName); - if (existingPort.HasValue) - { - _logger.LogInformation( - "Reusing existing container for {Model} on port {Port}", ollamaName, existingPort.Value); - _modelPorts[ollamaName] = existingPort.Value; - return true; - } - - await RunDockerAsync(["rm", "-f", containerName], CancellationToken.None); - - var args = BuildDockerRunArgs(containerName, model, port); - _logger.LogInformation( - "Starting llama.cpp container for {Model} on port {Port}: docker {Args}", - ollamaName, port, string.Join(" ", args)); - try { + var existingPort = await FindExistingContainerPortAsync(containerName); + if (existingPort.HasValue) + { + _logger.LogInformation( + "Reusing existing container for {Model} on port {Port}", ollamaName, existingPort.Value); + _modelPorts[ollamaName] = existingPort.Value; + return true; + } + + await RunDockerAsync(["rm", "-f", containerName], CancellationToken.None); + + var args = BuildDockerRunArgs(containerName, model, port); + _logger.LogInformation( + "Starting llama.cpp container for {Model} on port {Port}: docker {Args}", + ollamaName, port, string.Join(" ", args)); + var (exitCode, output) = await RunDockerWithOutputAsync(args, cancellationToken); if (exitCode != 0) { _logger.LogError( "Failed to start llama.cpp container for {Model}, exit code: {ExitCode}, output: {Output}", ollamaName, exitCode, output); + MarkModelAsFallback(ollamaName); return false; } @@ -180,12 +260,21 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable "llama.cpp container for {Model} started on port {Port}. Waiting for it to become ready...", ollamaName, port); - var ready = await WaitForServerReadyAsync("localhost", port, cancellationToken); - if (!ready) + var result = await WaitForServerReadyAsync("localhost", port, containerName, cancellationToken); + if (result != ContainerStartResult.Ready) { + if (result == ContainerStartResult.ContainerExited) + { + MarkModelAsPermanentFallback(ollamaName); + } + else + { + MarkModelAsFallback(ollamaName); + } + _logger.LogError( - "llama.cpp container for {Model} did not become ready on port {Port} within {Timeout}. Stopping it.", - ollamaName, port, ContainerStartTimeout); + "llama.cpp container for {Model} did not become ready on port {Port} within {Timeout} ({Result}). Stopping it.", + ollamaName, port, ContainerStartTimeout, result); try { @@ -207,13 +296,18 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable catch (Exception ex) { _logger.LogError(ex, "Failed to start llama.cpp container for {Model}", ollamaName); + MarkModelAsFallback(ollamaName); return false; } + finally + { + ReleaseReservedPort(port); + } } public async Task StopModelContainerAsync(string ollamaModelName, CancellationToken cancellationToken) { - if (!_modelPorts.TryRemove(ollamaModelName, out _)) + if (!RemoveModelPort(ollamaModelName)) { _logger.LogWarning("No running container found for model {Model}", ollamaModelName); return false; @@ -227,6 +321,7 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable { await RunDockerAsync(["stop", "--time", "10", containerName], cancellationToken); await RunDockerAsync(["rm", "-f", containerName], cancellationToken); + ClearModelFallback(ollamaModelName); return true; } catch (Exception ex) @@ -262,6 +357,8 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable } _modelPorts.Clear(); + _reservedPorts.Clear(); + _fallbackModels.Clear(); } public async Task TestDockerAsync() @@ -389,7 +486,6 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable { "run", "-d", - "--rm", "--label", $"{NginoContainerLabel}=true", "--name", containerName, "-p", $"{port}:{port}", @@ -413,9 +509,12 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable args.Add("-m"); args.Add($"/models/blobs/{blobFile}"); args.Add("-ngl"); - args.Add("999"); - args.Add("--parallel"); - args.Add("4"); + args.Add("auto"); + if (_parallel.HasValue) + { + args.Add("--parallel"); + args.Add(_parallel.Value.ToString()); + } args.Add("--host"); args.Add("0.0.0.0"); args.Add("--port"); @@ -476,25 +575,51 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable private int FindAvailablePort() { - var usedPorts = new HashSet(_modelPorts.Values); - var port = _basePort; - - while (usedPorts.Contains(port)) + lock (_portAllocationLock) { - port++; - } + var usedPorts = new HashSet(_modelPorts.Values); + foreach (var reservedPort in _reservedPorts.Keys) + { + usedPorts.Add(reservedPort); + } - return port; + var port = _basePort; + + while (usedPorts.Contains(port)) + { + port++; + } + + _reservedPorts[port] = 0; + return port; + } } - private static async Task WaitForServerReadyAsync( - string host, int port, CancellationToken cancellationToken) + private void ReleaseReservedPort(int port) + { + _reservedPorts.TryRemove(port, out _); + } + + private bool RemoveModelPort(string ollamaModelName) + { + if (_modelPorts.TryRemove(ollamaModelName, out var port)) + { + ReleaseReservedPort(port); + return true; + } + + return false; + } + + private async Task WaitForServerReadyAsync( + string host, int port, string containerName, CancellationToken cancellationToken) { var deadline = DateTime.UtcNow + ContainerStartTimeout; - if (!await WaitForTcpPortAsync(host, port, deadline, cancellationToken)) + var tcpResult = await WaitForTcpPortAsync(host, port, containerName, deadline, cancellationToken); + if (tcpResult != ContainerStartResult.Ready) { - return false; + return tcpResult; } using var handler = new SocketsHttpHandler @@ -508,41 +633,57 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable { cancellationToken.ThrowIfCancellationRequested(); + if (!await IsDockerContainerRunningAsync(containerName)) + { + await LogContainerOutputAsync(containerName); + return ContainerStartResult.ContainerExited; + } + try { using var response = await httpClient.GetAsync( $"http://{host}:{port}/health", cancellationToken); if (response.IsSuccessStatusCode) { - return true; + return ContainerStartResult.Ready; } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { } - catch (Exception) + catch (HttpRequestException ex) { - return false; + _logger.LogDebug(ex, "Health probe of llama.cpp container {ContainerName} failed; retrying.", containerName); + } + catch (IOException ex) + { + _logger.LogDebug(ex, "Health probe of llama.cpp container {ContainerName} failed; retrying.", containerName); } await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); } - return false; + return ContainerStartResult.TimedOut; } - private static async Task WaitForTcpPortAsync( - string host, int port, DateTime deadline, CancellationToken cancellationToken) + private async Task WaitForTcpPortAsync( + string host, int port, string containerName, DateTime deadline, CancellationToken cancellationToken) { while (DateTime.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); + if (!await IsDockerContainerRunningAsync(containerName)) + { + await LogContainerOutputAsync(containerName); + return ContainerStartResult.ContainerExited; + } + try { using var client = new TcpClient(); await client.ConnectAsync(host, port, cancellationToken); - return true; + return ContainerStartResult.Ready; } catch (OperationCanceledException) { @@ -555,7 +696,49 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); } - return false; + return ContainerStartResult.TimedOut; + } + + private async Task IsDockerContainerRunningAsync(string containerName) + { + try + { + var (exitCode, output) = await RunDockerWithOutputAsync( + ["inspect", "-f", "{{.State.Running}}", containerName], + CancellationToken.None); + return exitCode == 0 && string.Equals(output.Trim(), "true", StringComparison.OrdinalIgnoreCase); + } + catch + { + return true; + } + } + + private async Task LogContainerOutputAsync(string containerName) + { + try + { + var (_, output) = await RunDockerWithOutputAsync( + ["logs", "--tail", "100", containerName], + CancellationToken.None); + + if (!string.IsNullOrWhiteSpace(output)) + { + _logger.LogError( + "llama.cpp container {ContainerName} exited before becoming ready. Last output:\n{Output}", + containerName, output); + } + else + { + _logger.LogError( + "llama.cpp container {ContainerName} exited before becoming ready, but produced no output.", + containerName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read logs of container {ContainerName}", containerName); + } } private static string SanitizeContainerName(string name) @@ -655,6 +838,13 @@ internal sealed partial class LlamaCppManager : IAsyncDisposable [GeneratedRegex(@"[^a-zA-Z0-9_.-]")] private static partial Regex InvalidContainerNameChars(); + + private enum ContainerStartResult + { + Ready, + ContainerExited, + TimedOut + } } internal sealed record LlamaCppModel diff --git a/src/Ngino.Client/Program.cs b/src/Ngino.Client/Program.cs index b9617f4..5c508a6 100644 --- a/src/Ngino.Client/Program.cs +++ b/src/Ngino.Client/Program.cs @@ -22,8 +22,15 @@ try Console.WriteLine($" ollama models path: {options.UseOllamaModelsPath ?? "(not set)"}"); Console.WriteLine($" llama.cpp docker image: {options.LlamaCppDockerImage ?? "(auto)"}"); Console.WriteLine($" llama.cpp base port: {options.LlamaCppBasePort}"); + Console.WriteLine( + options.LlamaCppParallel.HasValue + ? $" llama.cpp parallel slots: {options.LlamaCppParallel.Value}" + : " llama.cpp parallel slots: (llama.cpp default)"); } + var logDirectory = options.LogDirectory ?? Path.Combine(AppContext.BaseDirectory, "Logs"); + Console.WriteLine($" log directory: {logDirectory}"); + // Args are parsed by ClientOptions; keep them away from the host configuration. var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); builder.Services.AddSingleton(options); @@ -32,6 +39,7 @@ try builder.Services.AddWindowsService(service => service.ServiceName = "NginoClient"); // The EventLog provider defaults to Warning; connection state is worth seeing there. builder.Logging.AddFilter("Ngino.Client", LogLevel.Information); + builder.Logging.AddProvider(new FileLoggerProvider(logDirectory)); await builder.Build().RunAsync(); return 0; diff --git a/src/Ngino.Client/TunnelClient.cs b/src/Ngino.Client/TunnelClient.cs index 498401d..9f49667 100644 --- a/src/Ngino.Client/TunnelClient.cs +++ b/src/Ngino.Client/TunnelClient.cs @@ -47,7 +47,9 @@ internal sealed class TunnelClient _options.UseOllamaModelsPath, _options.LlamaCppDockerImage, _options.LlamaCppBasePort, - _logger); + _logger, + _options.LlamaCppFallbackCooldown, + _options.LlamaCppParallel); } } @@ -477,13 +479,16 @@ internal sealed class TunnelClient var started = await _llamaCppManager.StartModelContainerAsync(model, cancellationToken); if (!started) { - return new TunnelMessage - { - Type = TunnelMessageTypes.ModelCommandResult, - RequestId = message.RequestId, - StatusCode = 500, - Error = $"Unable to load model '{modelName}'." - }; + _logger.LogWarning( + "Unable to load model '{Model}' via llama.cpp. Falling back to Ollama upstream.", + modelName); + + using var fallbackRequest = BuildModelCommandRequest(_options.Upstream, "load", modelName); + using var fallbackResponse = await _httpClient.SendAsync( + fallbackRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var fallbackBody = await fallbackResponse.Content.ReadAsByteArrayAsync(cancellationToken); + + return BuildModelCommandResult(message.RequestId, fallbackResponse, fallbackBody); } return BuildModelCommandResult(message.RequestId, 200, "OK", []); @@ -494,13 +499,18 @@ internal sealed class TunnelClient var stopped = await _llamaCppManager!.StopModelContainerAsync(modelName!, cancellationToken); if (!stopped) { - return new TunnelMessage - { - Type = TunnelMessageTypes.ModelCommandResult, - RequestId = message.RequestId, - StatusCode = 404, - Error = $"No running llama.cpp container for model '{modelName}'." - }; + _logger.LogWarning( + "No running llama.cpp container for model '{Model}'. Falling back to Ollama upstream to unload it.", + modelName); + + _llamaCppManager.ClearModelFallback(modelName!); + + using var fallbackRequest = BuildModelCommandRequest(_options.Upstream, "unload", modelName); + using var fallbackResponse = await _httpClient.SendAsync( + fallbackRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var fallbackBody = await fallbackResponse.Content.ReadAsByteArrayAsync(cancellationToken); + + return BuildModelCommandResult(message.RequestId, fallbackResponse, fallbackBody); } return BuildModelCommandResult(message.RequestId, 200, "OK", []); @@ -713,19 +723,29 @@ internal sealed class TunnelClient if (model is not null) { - _logger.LogInformation( - "Request for model '{Model}' but no llama.cpp container is running. Starting one on demand...", - modelName); - - var started = await _llamaCppManager.StartModelContainerAsync(model, cancellationToken); - if (started) + if (_llamaCppManager.IsModelOnFallback(modelName)) { - effectiveUpstream = _llamaCppManager.GetUpstream(modelName); + _logger.LogInformation( + "Model '{Model}' previously failed to load via llama.cpp. Routing directly to the Ollama upstream.", + modelName); } else { - await SendModelLoadErrorAsync(socket, message, modelName, cancellationToken); - return; + _logger.LogInformation( + "Request for model '{Model}' but no llama.cpp container is running. Starting one on demand...", + modelName); + + var started = await _llamaCppManager.StartModelContainerAsync(model, cancellationToken); + if (started) + { + effectiveUpstream = _llamaCppManager.GetUpstream(modelName); + } + else + { + _logger.LogWarning( + "Unable to load model '{Model}' via llama.cpp. Falling back to the Ollama upstream.", + modelName); + } } } else @@ -830,50 +850,6 @@ internal sealed class TunnelClient } } - private async Task SendModelLoadErrorAsync( - ClientWebSocket socket, TunnelMessage message, string modelName, CancellationToken cancellationToken) - { - _logger.LogWarning( - "Unable to load model '{Model}' via llama.cpp. Notifying caller.", modelName); - - var body = JsonSerializer.SerializeToUtf8Bytes( - new { error = $"Unable to load model '{modelName}'" }, - JsonOptions); - - _pendingRequestBodies.TryRemove(message.RequestId, out _); - - await SendAsync( - socket, - new TunnelMessage - { - Type = TunnelMessageTypes.HttpResponseHeaders, - RequestId = message.RequestId, - StatusCode = 500, - ReasonPhrase = "Internal Server Error", - Headers = [new HeaderPair("Content-Type", "application/json")] - }, - CancellationToken.None); - - await SendAsync( - socket, - new TunnelMessage - { - Type = TunnelMessageTypes.HttpResponseBody, - RequestId = message.RequestId, - Body = body - }, - CancellationToken.None); - - await SendAsync( - socket, - new TunnelMessage - { - Type = TunnelMessageTypes.HttpResponseComplete, - RequestId = message.RequestId - }, - CancellationToken.None); - } - private void CancelAllActiveRequests() { foreach (var pair in _activeRequests.ToArray())