Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d33a25bfa5 | ||
|
|
b849cfd4b7 | ||
|
|
44b09ffa86 | ||
|
|
2b56bc53b6 | ||
|
|
faba272480 | ||
|
|
9361aaf2fc | ||
|
|
ded9030ede | ||
|
|
afc85e4802 | ||
|
|
5b7ae55cc8 | ||
|
|
2ca5ca2da2 | ||
|
|
7a76acc247 | ||
|
|
d567966d41 |
@@ -1,8 +1,10 @@
|
|||||||
# Ngino
|
# <img src="docs/images/Ngino_logo_symbol.png" width="25" alt="logo symbol"/> Ngino
|
||||||
|
|
||||||
Ngino is a small outbound HTTP tunnel for running Ollama (or vLLM, etc.) on GPU workstations while exposing the API from a server that cannot reach those workstations directly.
|
Ngino is a small outbound HTTP tunnel for running Ollama, llama.cpp, vLLM, etc. on GPU workstations while exposing the API from a server that cannot reach those workstations directly.
|
||||||
|
|
||||||
The client opens and maintains a WebSocket connection to the server. The server accepts normal HTTP requests and forwards them through that WebSocket to the client. The client then calls a local upstream such as `http://localhost:11434` and streams the response back.
|
<img src="docs/images/Ngino_logo_full.png" alt="logo symbol"/>
|
||||||
|
|
||||||
|
The client opens and maintains a WebSocket connection to the server. The server accepts normal HTTP requests from users and forwards them through that WebSocket to the client. The client then calls a local upstream such as `http://localhost:11434` and streams the response back.
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -15,8 +17,8 @@ The server provides
|
|||||||
- An API with
|
- An API with
|
||||||
- Authentication via user keys
|
- Authentication via user keys
|
||||||
- Authorization (planned)
|
- Authorization (planned)
|
||||||
- Load balancing (Scale your AI strategy horizontally!)
|
- Load balancing (Scale your AI inferencing horizontally by adding more nodes!)
|
||||||
- (Ollama-only) Model management (install, remove, load, unload models)
|
- (Ollama-based) GGUF Model management (install, remove, load, unload models)
|
||||||
- Client monitoring
|
- Client monitoring
|
||||||
- Who is active
|
- Who is active
|
||||||
- What models are running
|
- What models are running
|
||||||
@@ -129,9 +131,35 @@ Linux:
|
|||||||
```bash
|
```bash
|
||||||
sudo bash deploy/install-client.sh --server http://your-server:5050 --token "change-me"
|
sudo bash deploy/install-client.sh --server http://your-server:5050 --token "change-me"
|
||||||
```
|
```
|
||||||
|
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 --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 --llama-cpp-parallel 128
|
||||||
|
```
|
||||||
|
|
||||||
Options: `--server`, `--token` (required); `--client-id`, `--upstream`, `--install-dir`, `--service-name`, `--no-ollama` (optional). Missing required values are prompted interactively.
|
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 <dir>` | Path to Ollama models directory (`manifests/blobs`); required with the flag above |
|
||||||
|
| `--llama-cpp-docker-image <img>` | Docker image; defaults to auto-detected (rocm/cuda/cpu) |
|
||||||
|
| `--llama-cpp-base-port <num>` | Base port for containers; defaults to `8081` |
|
||||||
|
| `--llama-cpp-parallel <num>` | llama.cpp parallel slots per container; if unset, llama.cpp's own default is used (which is `1`) |
|
||||||
|
| `--llama-cpp-fallback-cooldown <sec>` | Seconds before llama.cpp is retried after a failed container start; defaults to `180` |
|
||||||
|
| `--log-dir <dir>` | Directory for log files; defaults to `<app dir>/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`.
|
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
|
## Notes
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ param(
|
|||||||
[string]$InstallDir = "$env:ProgramFiles\Ngino Client",
|
[string]$InstallDir = "$env:ProgramFiles\Ngino Client",
|
||||||
[string]$ServiceName = "NginoClient",
|
[string]$ServiceName = "NginoClient",
|
||||||
[switch]$InsecureSkipTlsVerify,
|
[switch]$InsecureSkipTlsVerify,
|
||||||
[switch]$NoOllama
|
[switch]$NoOllama,
|
||||||
|
[switch]$UseLlamaCppViaDocker,
|
||||||
|
[string]$UseOllamaModelsPath = "",
|
||||||
|
[string]$LlamaCppDockerImage = "",
|
||||||
|
[int]$LlamaCppBasePort = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -54,6 +58,14 @@ if ([string]::IsNullOrWhiteSpace($Token)) {
|
|||||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) }
|
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) }
|
||||||
}
|
}
|
||||||
if ([string]::IsNullOrWhiteSpace($Token)) { throw "Token is required." }
|
if ([string]::IsNullOrWhiteSpace($Token)) { throw "Token is required." }
|
||||||
|
|
||||||
|
if ($UseLlamaCppViaDocker -and [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
|
||||||
|
$UseOllamaModelsPath = Read-Host "Ollama models path (e.g. C:\Users\user\.ollama\models)"
|
||||||
|
}
|
||||||
|
if ($UseLlamaCppViaDocker -and [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
|
||||||
|
throw "Ollama models path is required with -UseLlamaCppViaDocker."
|
||||||
|
}
|
||||||
|
|
||||||
if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." }
|
if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." }
|
||||||
|
|
||||||
$scriptDir = $PSScriptRoot
|
$scriptDir = $PSScriptRoot
|
||||||
@@ -149,6 +161,18 @@ $serviceEnvironment = @(
|
|||||||
"DOTNET_CLI_TELEMETRY_OPTOUT=1",
|
"DOTNET_CLI_TELEMETRY_OPTOUT=1",
|
||||||
"DOTNET_NOLOGO=1"
|
"DOTNET_NOLOGO=1"
|
||||||
)
|
)
|
||||||
|
if ($UseLlamaCppViaDocker) {
|
||||||
|
$serviceEnvironment += "NGINO_USE_LLAMA_CPP_VIA_DOCKER=true"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($UseOllamaModelsPath)) {
|
||||||
|
$serviceEnvironment += "NGINO_USE_OLLAMA_MODELS_PATH=$UseOllamaModelsPath"
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($LlamaCppDockerImage)) {
|
||||||
|
$serviceEnvironment += "NGINO_LLAMA_CPP_DOCKER_IMAGE=$LlamaCppDockerImage"
|
||||||
|
}
|
||||||
|
if ($LlamaCppBasePort -gt 0) {
|
||||||
|
$serviceEnvironment += "NGINO_LLAMA_CPP_BASE_PORT=$LlamaCppBasePort"
|
||||||
|
}
|
||||||
|
}
|
||||||
if ($InsecureSkipTlsVerify) {
|
if ($InsecureSkipTlsVerify) {
|
||||||
Write-Warn "Server TLS certificate validation is disabled for $ServiceName."
|
Write-Warn "Server TLS certificate validation is disabled for $ServiceName."
|
||||||
}
|
}
|
||||||
@@ -169,6 +193,9 @@ Write-Host " Client ID: $ClientId"
|
|||||||
Write-Host " Upstream: $Upstream"
|
Write-Host " Upstream: $Upstream"
|
||||||
Write-Host " Service: $ServiceName"
|
Write-Host " Service: $ServiceName"
|
||||||
Write-Host " Install dir: $InstallDir"
|
Write-Host " Install dir: $InstallDir"
|
||||||
|
if ($UseLlamaCppViaDocker) {
|
||||||
|
Write-Host " llama.cpp: enabled (models: $UseOllamaModelsPath)"
|
||||||
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " Manage: Get-Service $ServiceName | Start-Service/Stop-Service/Restart-Service"
|
Write-Host " Manage: Get-Service $ServiceName | Start-Service/Stop-Service/Restart-Service"
|
||||||
Write-Host " Logs: Get-WinEvent -LogName Application | Where-Object ProviderName -eq NginoClient"
|
Write-Host " Logs: Get-WinEvent -LogName Application | Where-Object ProviderName -eq NginoClient"
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ TOKEN=""
|
|||||||
CLIENT_ID="$(hostname -s 2>/dev/null || echo "linux-client")"
|
CLIENT_ID="$(hostname -s 2>/dev/null || echo "linux-client")"
|
||||||
UPSTREAM="$DEFAULT_UPSTREAM"
|
UPSTREAM="$DEFAULT_UPSTREAM"
|
||||||
SKIP_OLLAMA=false
|
SKIP_OLLAMA=false
|
||||||
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -45,11 +52,27 @@ Optional:
|
|||||||
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
||||||
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
||||||
--no-ollama Skip Ollama installation and status check
|
--no-ollama Skip Ollama installation and status check
|
||||||
|
--use-llama-cpp-via-docker
|
||||||
|
Use llama.cpp via Docker for inference instead of Ollama
|
||||||
|
--use-ollama-models-path <dir>
|
||||||
|
Path to Ollama models directory (manifests/blobs); required with --use-llama-cpp-via-docker
|
||||||
|
--llama-cpp-docker-image <img>
|
||||||
|
llama.cpp Docker image; defaults to auto-detected (rocm/cuda/cpu)
|
||||||
|
--llama-cpp-base-port <num>
|
||||||
|
Base port for llama.cpp containers; defaults to 8081
|
||||||
|
--llama-cpp-parallel <num>
|
||||||
|
llama.cpp parallel slots per container; if unset, llama.cpp's own default is used
|
||||||
|
--llama-cpp-fallback-cooldown <sec>
|
||||||
|
Seconds before llama.cpp is retried after a failed container start; defaults to 180
|
||||||
|
--log-dir <dir> Directory for log files; defaults to <install-dir>/Logs
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$0 --server http://gpu-server:5050 --token "my-secret"
|
$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" --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 \\
|
||||||
|
--llama-cpp-parallel 128
|
||||||
EOF
|
EOF
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -57,15 +80,22 @@ EOF
|
|||||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--server) SERVER_URL="$2"; shift 2 ;;
|
--server) SERVER_URL="$2"; shift 2 ;;
|
||||||
--token) TOKEN="$2"; shift 2 ;;
|
--token) TOKEN="$2"; shift 2 ;;
|
||||||
--client-id) CLIENT_ID="$2"; shift 2 ;;
|
--client-id) CLIENT_ID="$2"; shift 2 ;;
|
||||||
--upstream) UPSTREAM="$2"; shift 2 ;;
|
--upstream) UPSTREAM="$2"; shift 2 ;;
|
||||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
||||||
--no-ollama) SKIP_OLLAMA=true; shift ;;
|
--no-ollama) SKIP_OLLAMA=true; shift ;;
|
||||||
-h|--help) usage ;;
|
--use-llama-cpp-via-docker) USE_LLAMA_CPP_VIA_DOCKER=true; shift ;;
|
||||||
*) die "Unknown option: $1" ;;
|
--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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -85,6 +115,13 @@ if [[ -z "$TOKEN" ]]; then
|
|||||||
die "Token is required."
|
die "Token is required."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" && -z "$USE_OLLAMA_MODELS_PATH" ]]; then
|
||||||
|
read -rp "Ollama models path (e.g. /usr/share/ollama/.ollama/models): " USE_OLLAMA_MODELS_PATH
|
||||||
|
fi
|
||||||
|
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" && -z "$USE_OLLAMA_MODELS_PATH" ]]; then
|
||||||
|
die "Ollama models path is required with --use-llama-cpp-via-docker."
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Root check ────────────────────────────────────────────────────────────────
|
# ── Root check ────────────────────────────────────────────────────────────────
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
die "This script must be run as root (or with sudo)."
|
die "This script must be run as root (or with sudo)."
|
||||||
@@ -254,6 +291,14 @@ fi
|
|||||||
|
|
||||||
info "Build successful."
|
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 ───────────────────────────────────────────────────────────────────
|
# ── Install ───────────────────────────────────────────────────────────────────
|
||||||
info "Installing to $INSTALL_DIR..."
|
info "Installing to $INSTALL_DIR..."
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
@@ -265,23 +310,41 @@ info "Client installed to $INSTALL_DIR."
|
|||||||
# ── Write environment file (avoids shell injection in unit file) ─────────────
|
# ── Write environment file (avoids shell injection in unit file) ─────────────
|
||||||
ENV_DIR="/etc/ngino-client"
|
ENV_DIR="/etc/ngino-client"
|
||||||
mkdir -p "$ENV_DIR"
|
mkdir -p "$ENV_DIR"
|
||||||
printf 'NGINO_TOKEN=%s\n' "$TOKEN" > "$ENV_DIR/env"
|
{
|
||||||
|
printf 'NGINO_TOKEN=%s\n' "$TOKEN"
|
||||||
|
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" ]]; then
|
||||||
|
printf 'NGINO_USE_LLAMA_CPP_VIA_DOCKER=true\n'
|
||||||
|
if [[ -n "$USE_OLLAMA_MODELS_PATH" ]]; then
|
||||||
|
printf 'NGINO_USE_OLLAMA_MODELS_PATH=%s\n' "$USE_OLLAMA_MODELS_PATH"
|
||||||
|
fi
|
||||||
|
if [[ -n "$LLAMA_CPP_DOCKER_IMAGE" ]]; then
|
||||||
|
printf 'NGINO_LLAMA_CPP_DOCKER_IMAGE=%s\n' "$LLAMA_CPP_DOCKER_IMAGE"
|
||||||
|
fi
|
||||||
|
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"
|
chmod 600 "$ENV_DIR/env"
|
||||||
info "Environment file written to $ENV_DIR/env (mode 0600)."
|
info "Environment file written to $ENV_DIR/env (mode 0600)."
|
||||||
|
|
||||||
# ── Create systemd service ───────────────────────────────────────────────────
|
# ── Create systemd service ───────────────────────────────────────────────────
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.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" <<EOF
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Ngino Tunnel Client
|
Description=Ngino Tunnel Client
|
||||||
After=network-online.target
|
After=network-online.target docker.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target docker.service
|
||||||
$([ "$SKIP_OLLAMA" = "false" ] && echo "After=ollama.service")
|
$([ "$SKIP_OLLAMA" = "false" ] && echo "After=ollama.service")
|
||||||
$([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service")
|
$([ "$SKIP_OLLAMA" = "false" ] && echo "Wants=ollama.service")
|
||||||
|
|
||||||
@@ -318,6 +381,9 @@ echo " Client ID: $CLIENT_ID"
|
|||||||
echo " Upstream: $UPSTREAM"
|
echo " Upstream: $UPSTREAM"
|
||||||
echo " Service: $SERVICE_NAME"
|
echo " Service: $SERVICE_NAME"
|
||||||
echo " Install dir: $INSTALL_DIR"
|
echo " Install dir: $INSTALL_DIR"
|
||||||
|
if [[ "$USE_LLAMA_CPP_VIA_DOCKER" == "true" ]]; then
|
||||||
|
echo " llama.cpp: enabled (models: $USE_OLLAMA_MODELS_PATH)"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
echo " Manage: systemctl {start|stop|restart|status} $SERVICE_NAME"
|
echo " Manage: systemctl {start|stop|restart|status} $SERVICE_NAME"
|
||||||
echo " Logs: journalctl -u $SERVICE_NAME -f"
|
echo " Logs: journalctl -u $SERVICE_NAME -f"
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
#Requires -RunAsAdministrator
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$InstallDir = "$env:ProgramFiles\Ngino Client",
|
||||||
|
[string]$ServiceName = "NginoClient"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Write-Info([string]$Message) { Write-Host "[INFO] $Message" -ForegroundColor Green }
|
||||||
|
|
||||||
|
if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." }
|
||||||
|
|
||||||
|
# ── Stop and delete the Windows service ───────────────────────────────────────
|
||||||
|
$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($service) {
|
||||||
|
if ($service.Status -ne "Stopped") {
|
||||||
|
Write-Info "Stopping service $ServiceName..."
|
||||||
|
Stop-Service -Name $ServiceName -Force
|
||||||
|
$service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30))
|
||||||
|
}
|
||||||
|
Write-Info "Deleting service $ServiceName..."
|
||||||
|
& sc.exe delete $ServiceName
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Could not delete Windows service $ServiceName." }
|
||||||
|
} else {
|
||||||
|
Write-Info "Service $ServiceName does not exist; skipping."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Remove install directory ──────────────────────────────────────────────────
|
||||||
|
if (Test-Path -LiteralPath $InstallDir) {
|
||||||
|
Write-Info "Removing install directory $InstallDir..."
|
||||||
|
Remove-Item -LiteralPath $InstallDir -Recurse -Force
|
||||||
|
} else {
|
||||||
|
Write-Info "Install directory $InstallDir does not exist; skipping."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Info "Uninstall complete."
|
||||||
|
Write-Host " Service: $ServiceName (stopped and deleted)"
|
||||||
|
Write-Host " Install dir: $InstallDir (removed)"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||||
|
DEFAULT_INSTALL_DIR="/opt/ngino-client"
|
||||||
|
DEFAULT_SERVICE_NAME="ngino-client"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||||
|
SERVICE_NAME="$DEFAULT_SERVICE_NAME"
|
||||||
|
|
||||||
|
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
die() { err "$@"; exit 1; }
|
||||||
|
|
||||||
|
# ── Usage ─────────────────────────────────────────────────────────────────────
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [OPTIONS]
|
||||||
|
|
||||||
|
Uninstalls the Ngino client systemd service and removes its files.
|
||||||
|
Does NOT remove .NET SDK, Ollama, or Docker.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--install-dir <dir> Install directory; defaults to $DEFAULT_INSTALL_DIR
|
||||||
|
--service-name <n> systemd service name; defaults to $DEFAULT_SERVICE_NAME
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$0
|
||||||
|
$0 --install-dir /opt/ngino-client --service-name ngino-client
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||||
|
--service-name) SERVICE_NAME="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) die "Unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Root check ────────────────────────────────────────────────────────────────
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
die "This script must be run as root (or with sudo)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
ENV_DIR="/etc/ngino-client"
|
||||||
|
|
||||||
|
# ── Stop and disable the service ──────────────────────────────────────────────
|
||||||
|
if systemctl list-unit-files "$SERVICE_NAME.service" &>/dev/null 2>&1; then
|
||||||
|
info "Stopping service $SERVICE_NAME..."
|
||||||
|
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||||
|
info "Disabling service $SERVICE_NAME..."
|
||||||
|
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Remove service unit file ──────────────────────────────────────────────────
|
||||||
|
if [[ -f "$SERVICE_FILE" ]]; then
|
||||||
|
info "Removing service unit file $SERVICE_FILE..."
|
||||||
|
rm -f "$SERVICE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# ── Remove install directory ──────────────────────────────────────────────────
|
||||||
|
if [[ -d "$INSTALL_DIR" ]]; then
|
||||||
|
info "Removing install directory $INSTALL_DIR..."
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Remove environment file ───────────────────────────────────────────────────
|
||||||
|
if [[ -d "$ENV_DIR" ]]; then
|
||||||
|
info "Removing environment directory $ENV_DIR..."
|
||||||
|
rm -rf "$ENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
info "Uninstall complete."
|
||||||
|
echo " Service: $SERVICE_NAME (stopped, disabled, unit removed)"
|
||||||
|
echo " Install dir: $INSTALL_DIR (removed)"
|
||||||
|
echo " Env dir: $ENV_DIR (removed)"
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1280 720"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="title desc"
|
||||||
|
version="1.1"
|
||||||
|
id="svg10"
|
||||||
|
sodipodi:docname="Ngino_logo.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
inkscape:export-filename="Ngino_logo_full_dark.png"
|
||||||
|
inkscape:export-xdpi="125.532"
|
||||||
|
inkscape:export-ydpi="125.532"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
|
||||||
|
id="namedview10"
|
||||||
|
pagecolor="#000000"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#000000"
|
||||||
|
inkscape:zoom="0.7671875"
|
||||||
|
inkscape:cx="391.69043"
|
||||||
|
inkscape:cy="406.02851"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1412"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer4"
|
||||||
|
inkscape:export-bgcolor="#00000000" /><title
|
||||||
|
id="title">Ngino logo</title><desc
|
||||||
|
id="desc">A fully vector llama outline with one input connection branching to three AI endpoints, beside the Ngino wordmark.</desc><defs
|
||||||
|
id="defs2"><linearGradient
|
||||||
|
id="swatch181"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop181" /></linearGradient><clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath15"><rect
|
||||||
|
style="fill:none;stroke:#000000;stroke-linecap:butt;stroke-linejoin:bevel;paint-order:stroke markers fill;stop-color:#000000"
|
||||||
|
id="rect15-7"
|
||||||
|
width="45.034248"
|
||||||
|
height="38.955639"
|
||||||
|
x="262.48285"
|
||||||
|
y="121.00073" /></clipPath><linearGradient
|
||||||
|
id="swatch7"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop7" /></linearGradient><linearGradient
|
||||||
|
id="linearGradient1"
|
||||||
|
inkscape:swatch="gradient"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3" /><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:0;"
|
||||||
|
offset="1"
|
||||||
|
id="stop4" /></linearGradient><linearGradient
|
||||||
|
id="routeGradient"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse"><stop
|
||||||
|
offset="0"
|
||||||
|
stop-color="#0697b5"
|
||||||
|
id="stop1" /><stop
|
||||||
|
offset="1"
|
||||||
|
stop-color="#45d05f"
|
||||||
|
id="stop2" /></linearGradient><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient10"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0"
|
||||||
|
gradientTransform="translate(50.835031)" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient24"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient25"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient26"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient27"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient28"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0"
|
||||||
|
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient29"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0"
|
||||||
|
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient30"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0"
|
||||||
|
gradientTransform="translate(18.248473,-20.855397)" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#routeGradient"
|
||||||
|
id="linearGradient31"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="95"
|
||||||
|
y1="0"
|
||||||
|
x2="430"
|
||||||
|
y2="0"
|
||||||
|
gradientTransform="translate(18.248473,-20.855397)" /><filter
|
||||||
|
inkscape:collect="always"
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter215"
|
||||||
|
x="-0.0021624053"
|
||||||
|
y="-0.0047179751"
|
||||||
|
width="1.0043248"
|
||||||
|
height="1.009436"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="1.0812026"
|
||||||
|
id="feGaussianBlur215" /></filter><filter
|
||||||
|
inkscape:collect="always"
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter215-8"
|
||||||
|
x="-0.003844276"
|
||||||
|
y="-0.0074139609"
|
||||||
|
width="1.0076886"
|
||||||
|
height="1.0148279"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="1.0812026"
|
||||||
|
id="feGaussianBlur215-8" /></filter><filter
|
||||||
|
inkscape:collect="always"
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter215-6"
|
||||||
|
x="-0.0051897726"
|
||||||
|
y="-0.0047179751"
|
||||||
|
width="1.0103795"
|
||||||
|
height="1.009436"><feGaussianBlur
|
||||||
|
inkscape:collect="always"
|
||||||
|
stdDeviation="1.0812026"
|
||||||
|
id="feGaussianBlur215-0" /></filter></defs><!-- Transparent background; no embedded raster image. --><rect
|
||||||
|
style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215)"
|
||||||
|
id="rect1"
|
||||||
|
width="1200"
|
||||||
|
height="550"
|
||||||
|
x="26.069246"
|
||||||
|
y="20.855396"
|
||||||
|
inkscape:label="background_full"
|
||||||
|
rx="100"
|
||||||
|
inkscape:highlight-color="#000000"
|
||||||
|
inkscape:export-filename="Ngino_logo_full.png"
|
||||||
|
inkscape:export-xdpi="153.60001"
|
||||||
|
inkscape:export-ydpi="153.60001" /><g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
inkscape:label="text"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:export-filename="Ngino_logo_text.png"
|
||||||
|
inkscape:export-xdpi="125.532"
|
||||||
|
inkscape:export-ydpi="125.532"><rect
|
||||||
|
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215-8)"
|
||||||
|
id="rect1-1"
|
||||||
|
width="675"
|
||||||
|
height="350"
|
||||||
|
x="458.96759"
|
||||||
|
y="152.75374"
|
||||||
|
inkscape:label="background_text"
|
||||||
|
rx="100"
|
||||||
|
inkscape:highlight-color="#000000"
|
||||||
|
inkscape:export-filename="Ngino_logo_full.png"
|
||||||
|
inkscape:export-xdpi="153.60001"
|
||||||
|
inkscape:export-ydpi="153.60001" /><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:37.3333px;line-height:1.25;font-family:Sans;-inkscape-font-specification:'Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;display:inline;fill:#000000;fill-opacity:1"
|
||||||
|
x="503.97342"
|
||||||
|
y="430.72964"
|
||||||
|
id="text2"
|
||||||
|
inkscape:label="sub"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan2"
|
||||||
|
x="503.97342"
|
||||||
|
y="430.72964">R E V E R S E P R O X Y F O R A I</tspan></text><text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-weight:900;font-size:170.667px;line-height:1.25;font-family:Montserrat;-inkscape-font-specification:'Montserrat, Heavy';display:inline;fill:#000000;fill-opacity:1"
|
||||||
|
x="496.78931"
|
||||||
|
y="357.61456"
|
||||||
|
id="text1"
|
||||||
|
inkscape:label="main"
|
||||||
|
inkscape:export-filename="Ngino_logo_text.png"
|
||||||
|
inkscape:export-xdpi="125.532"
|
||||||
|
inkscape:export-ydpi="125.532"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan1"
|
||||||
|
x="496.78931"
|
||||||
|
y="357.61456">Ngino</tspan></text></g><!-- Connection nodes --><!-- Wordmark --><rect
|
||||||
|
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:50;stroke-linecap:round;stroke-miterlimit:0.8;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter215-6)"
|
||||||
|
id="rect1-8"
|
||||||
|
width="500"
|
||||||
|
height="550"
|
||||||
|
x="28.825022"
|
||||||
|
y="21.104044"
|
||||||
|
inkscape:label="background_logo"
|
||||||
|
rx="100"
|
||||||
|
inkscape:highlight-color="#000000"
|
||||||
|
inkscape:export-filename="Ngino_logo_full.png"
|
||||||
|
inkscape:export-xdpi="153.60001"
|
||||||
|
inkscape:export-ydpi="153.60001" /><g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:label="Logo"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:export-filename="Ngino_Logo_Symbol.png"
|
||||||
|
inkscape:export-xdpi="125.532"
|
||||||
|
inkscape:export-ydpi="125.532"><g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer3"
|
||||||
|
inkscape:label="Body"
|
||||||
|
style="display:inline"><path
|
||||||
|
d="m 115.82078,398.64766 c -22.393081,-46.4277 -14.055,-69.1446 2.64154,-97.02036 C 143.97964,277.26884 147,275.30346 190,275.30346 h 56 c 36,0 53.48269,-18.60692 63.48269,-46.60692 6,-16 4,-43 5,-65 0,-20 6,-37.30346 16,-52.30346 l 0.39308,-26.965381 C 333.17923,71.427699 347,53 356,48 l 2,47 c 6,-19 17,-34 31,-40 l -1.69653,38.268839 c 13,3 22.69653,17.517311 28.69653,28.517311 18,3 29,6.17923 31,22.17923 0,14 -3.78615,18.08961 -16.78615,20.08961 l -37,7.21385 c 12,24 28.91039,76.76578 36.91039,108.76578 14,58 -6.24848,146.8554 -29.24848,202.8554 l -13,29 h -47 l -8,-35 c -4,-22 -23,-38 -48,-38 h -53.50305 c -27,0 -45,16 -49,40 l -6,33 h -47.12424 z"
|
||||||
|
id="path2"
|
||||||
|
sodipodi:nodetypes="ccsscccccccccccccccccsscccc"
|
||||||
|
style="display:inline;fill:none;stroke:url(#linearGradient27)"
|
||||||
|
stroke-width="13"
|
||||||
|
inkscape:label="body" /></g><g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer4"
|
||||||
|
inkscape:label="Symbol"
|
||||||
|
style="display:inline"><g
|
||||||
|
fill="url(#routeGradient)"
|
||||||
|
id="g9"
|
||||||
|
style="display:inline;fill:url(#linearGradient27)"
|
||||||
|
transform="translate(18.248473,-20.855397)"
|
||||||
|
inkscape:label="Symbol_circles"><circle
|
||||||
|
cx="354"
|
||||||
|
cy="398"
|
||||||
|
r="15"
|
||||||
|
id="circle9"
|
||||||
|
style="fill:url(#linearGradient26)"
|
||||||
|
inkscape:label="ball_fork_down" /><circle
|
||||||
|
cx="354"
|
||||||
|
cy="356"
|
||||||
|
r="15"
|
||||||
|
id="circle8"
|
||||||
|
style="display:inline;fill:url(#linearGradient25)"
|
||||||
|
inkscape:label="ball_fork_mid" /><circle
|
||||||
|
cx="354"
|
||||||
|
cy="314"
|
||||||
|
r="15"
|
||||||
|
id="circle7"
|
||||||
|
style="display:inline;fill:url(#linearGradient24)"
|
||||||
|
inkscape:label="ball_fork_up" /><circle
|
||||||
|
cx="130.83504"
|
||||||
|
cy="356"
|
||||||
|
r="15"
|
||||||
|
id="circle6"
|
||||||
|
style="display:inline;fill:url(#linearGradient10)"
|
||||||
|
inkscape:label="ball_connector" /></g><g
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#routeGradient)"
|
||||||
|
stroke-width="13"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
id="g6"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:label="Symbol_paths"><!-- Llama silhouette --><!-- One input, three routed endpoints --><path
|
||||||
|
d="m 254.24847,335.1446 c 29,0 42,42 78,42 h 40"
|
||||||
|
id="path6"
|
||||||
|
style="display:inline;stroke:url(#linearGradient31)"
|
||||||
|
inkscape:label="fork_down" /><path
|
||||||
|
d="m 254.24847,335.1446 h 118"
|
||||||
|
id="path5"
|
||||||
|
style="display:inline;stroke:url(#linearGradient28)"
|
||||||
|
inkscape:label="fork_mid" /><path
|
||||||
|
d="m 254.24847,335.1446 c 29,0 42,-42 78,-42 h 40"
|
||||||
|
id="path4"
|
||||||
|
style="display:inline;stroke:url(#linearGradient30)"
|
||||||
|
inkscape:label="fork_up" /><path
|
||||||
|
d="M 150.38696,335.1446 H 254.24847"
|
||||||
|
id="path3"
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
style="display:inline;stroke:url(#linearGradient29)"
|
||||||
|
inkscape:label="connector"
|
||||||
|
inkscape:export-filename="Ngino_logo_symbol.png"
|
||||||
|
inkscape:export-xdpi="153.60001"
|
||||||
|
inkscape:export-ydpi="153.60001" /></g></g></g><metadata
|
||||||
|
id="metadata215"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:title>Ngino logo</dc:title></cc:Work></rdf:RDF></metadata></svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -20,6 +20,20 @@ internal sealed class ClientOptions
|
|||||||
|
|
||||||
public bool InsecureSkipTlsVerify { get; init; }
|
public bool InsecureSkipTlsVerify { get; init; }
|
||||||
|
|
||||||
|
public bool UseLlamaCppViaDocker { get; init; }
|
||||||
|
|
||||||
|
public string? UseOllamaModelsPath { get; init; }
|
||||||
|
|
||||||
|
public string? LlamaCppDockerImage { get; init; }
|
||||||
|
|
||||||
|
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
|
public Uri TunnelUri
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -56,21 +70,36 @@ internal sealed class ClientOptions
|
|||||||
ClientId = Read(values, "client-id", "NGINO_CLIENT_ID") ?? Environment.MachineName.ToLowerInvariant(),
|
ClientId = Read(values, "client-id", "NGINO_CLIENT_ID") ?? Environment.MachineName.ToLowerInvariant(),
|
||||||
ReconnectDelay = TimeSpan.FromSeconds(ReadInt(values, 5, "reconnect-delay", "NGINO_RECONNECT_DELAY_SECONDS")),
|
ReconnectDelay = TimeSpan.FromSeconds(ReadInt(values, 5, "reconnect-delay", "NGINO_RECONNECT_DELAY_SECONDS")),
|
||||||
ChunkSize = ReadInt(values, 64 * 1024, "chunk-size", "NGINO_CHUNK_SIZE"),
|
ChunkSize = ReadInt(values, 64 * 1024, "chunk-size", "NGINO_CHUNK_SIZE"),
|
||||||
InsecureSkipTlsVerify = ReadBool(values, false, "insecure-skip-tls-verify", "NGINO_INSECURE_SKIP_TLS_VERIFY")
|
InsecureSkipTlsVerify = ReadBool(values, false, "insecure-skip-tls-verify", "NGINO_INSECURE_SKIP_TLS_VERIFY"),
|
||||||
|
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"),
|
||||||
|
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"))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string Usage =>
|
public static string Usage =>
|
||||||
"""
|
"""
|
||||||
Ngino.Client options:
|
Ngino.Client options:
|
||||||
--server <url> Server base URL, e.g. http://my-server:5050
|
--server <url> Server base URL, e.g. http://my-server:5050
|
||||||
--upstream <url> Local upstream URL, e.g. http://localhost:11434
|
--upstream <url> Local upstream URL, e.g. http://localhost:11434
|
||||||
--token <value> Optional token matching the server
|
--token <value> Optional token matching the server
|
||||||
--client-id <name> Identifies this machine on the server; defaults to the machine name
|
--client-id <name> Identifies this machine on the server; defaults to the machine name
|
||||||
--tunnel-path <path> Defaults to /_ngino/tunnel
|
--tunnel-path <path> Defaults to /_ngino/tunnel
|
||||||
--reconnect-delay <sec> Defaults to 5
|
--reconnect-delay <sec> Defaults to 5
|
||||||
--chunk-size <bytes> Defaults to 65536
|
--chunk-size <bytes> Defaults to 65536
|
||||||
--insecure-skip-tls-verify Disable server TLS certificate validation (unsafe)
|
--insecure-skip-tls-verify Disable server TLS certificate validation (unsafe)
|
||||||
|
--use-llama-cpp-via-docker Use llama.cpp via Docker for inference instead of Ollama
|
||||||
|
--use-ollama-models-path <dir> Path to Ollama models directory (manifests/blobs), required with --use-llama-cpp-via-docker
|
||||||
|
--llama-cpp-docker-image <img> llama.cpp Docker image; defaults to auto-detected (rocm/cuda/cpu)
|
||||||
|
--llama-cpp-base-port <num> Base port for llama.cpp containers; defaults to 8081
|
||||||
|
--llama-cpp-parallel <num> llama.cpp parallel slots per container; if unset, llama.cpp's own default is used
|
||||||
|
--llama-cpp-fallback-cooldown <sec>
|
||||||
|
Seconds before llama.cpp is retried after a failed container start; defaults to 180
|
||||||
|
--log-dir <dir> Directory for log files; defaults to <app dir>/Logs
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||||
@@ -92,7 +121,7 @@ internal sealed class ClientOptions
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyValue[0].Equals("insecure-skip-tls-verify", StringComparison.OrdinalIgnoreCase))
|
if (IsBoolFlag(keyValue[0]))
|
||||||
{
|
{
|
||||||
values[keyValue[0]] = "true";
|
values[keyValue[0]] = "true";
|
||||||
continue;
|
continue;
|
||||||
@@ -109,6 +138,16 @@ internal sealed class ClientOptions
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsBoolFlag(string key)
|
||||||
|
{
|
||||||
|
return key switch
|
||||||
|
{
|
||||||
|
"insecure-skip-tls-verify" => true,
|
||||||
|
"use-llama-cpp-via-docker" => true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string? Read(Dictionary<string, string> values, params string[] keys)
|
private static string? Read(Dictionary<string, string> values, params string[] keys)
|
||||||
{
|
{
|
||||||
foreach (var key in keys)
|
foreach (var key in keys)
|
||||||
@@ -134,6 +173,12 @@ internal sealed class ClientOptions
|
|||||||
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
|
return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int? ReadOptionalInt(Dictionary<string, string> 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<string, string> values, bool fallback, params string[] keys)
|
private static bool ReadBool(Dictionary<string, string> values, bool fallback, params string[] keys)
|
||||||
{
|
{
|
||||||
var value = Read(values, keys);
|
var value = Read(values, keys);
|
||||||
@@ -154,4 +199,14 @@ internal sealed class ClientOptions
|
|||||||
|
|
||||||
private static string NormalizePath(string path) =>
|
private static string NormalizePath(string path) =>
|
||||||
path.StartsWith('/') ? path : $"/{path}";
|
path.StartsWith('/') ? path : $"/{path}";
|
||||||
|
|
||||||
|
private static string? NormalizeDirectoryPath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(TState state) where TState : notnull => null;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Trace;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Ngino.Client;
|
||||||
|
|
||||||
|
internal sealed partial class LlamaCppManager : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
private const int DefaultBasePort = 8081;
|
||||||
|
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<string, int> _modelPorts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<int, byte> _reservedPorts = new();
|
||||||
|
private readonly ConcurrentDictionary<string, DateTime> _fallbackModels = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _modelStartLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _portAllocationLock = new();
|
||||||
|
|
||||||
|
public LlamaCppManager(
|
||||||
|
string ollamaModelsPath,
|
||||||
|
string? dockerImage,
|
||||||
|
int? basePort,
|
||||||
|
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<LlamaCppManager>.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DockerImage => _dockerImage;
|
||||||
|
|
||||||
|
public List<LlamaCppModel> DiscoverModels()
|
||||||
|
{
|
||||||
|
var models = new List<LlamaCppModel>();
|
||||||
|
|
||||||
|
if (!Directory.Exists(_manifestsPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Ollama manifests path not found: {ManifestsPath}", _manifestsPath);
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var manifestPath in Directory.EnumerateFiles(_manifestsPath, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = ParseManifest(manifestPath);
|
||||||
|
if (model is not null)
|
||||||
|
{
|
||||||
|
models.Add(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to parse manifest: {ManifestPath}", manifestPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LlamaCppModel> DiscoverModelsWithBlob()
|
||||||
|
{
|
||||||
|
return DiscoverModels().Where(m => File.Exists(m.BlobPath)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsModelActive(string ollamaModelName)
|
||||||
|
{
|
||||||
|
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<bool> IsContainerRunningAsync(string ollamaModelName)
|
||||||
|
{
|
||||||
|
if (!_modelPorts.ContainsKey(ollamaModelName))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var containerName = SanitizeContainerName($"ngino-llamacpp-{ollamaModelName}");
|
||||||
|
var (exitCode, output) = await RunDockerWithOutputAsync(
|
||||||
|
["ps", "--filter", $"name=^{containerName}$", "--format", "{{.ID}}"],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var running = exitCode == 0 && !string.IsNullOrWhiteSpace(output);
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"llama.cpp container {ContainerName} is no longer running. Invalidating cached port for {Model}.",
|
||||||
|
containerName, ollamaModelName);
|
||||||
|
RemoveModelPort(ollamaModelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool RemoveModelMapping(string ollamaModelName)
|
||||||
|
{
|
||||||
|
if (RemoveModelPort(ollamaModelName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Removed stale llama.cpp port mapping for {Model}.",
|
||||||
|
ollamaModelName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Uri? GetUpstream(string ollamaModelName)
|
||||||
|
{
|
||||||
|
if (_modelPorts.TryGetValue(ollamaModelName, out var port))
|
||||||
|
{
|
||||||
|
return new Uri($"http://localhost:{port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> StartModelContainerAsync(LlamaCppModel model, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ollamaName = model.OllamaName;
|
||||||
|
if (string.IsNullOrWhiteSpace(ollamaName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Cannot start container: model has no Ollama name");
|
||||||
|
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<bool> 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}");
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"llama.cpp container for {Model} started on port {Port}. Waiting for it to become ready...",
|
||||||
|
ollamaName, port);
|
||||||
|
|
||||||
|
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} ({Result}). Stopping it.",
|
||||||
|
ollamaName, port, ContainerStartTimeout, result);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunDockerAsync(["stop", "--time", "10", containerName], CancellationToken.None);
|
||||||
|
await RunDockerAsync(["rm", "-f", containerName], CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception cleanupException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(cleanupException, "Failed to clean up container {ContainerName}", containerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_modelPorts[ollamaName] = port;
|
||||||
|
_logger.LogInformation("llama.cpp container for {Model} is ready on port {Port}.", ollamaName, port);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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<bool> StopModelContainerAsync(string ollamaModelName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!RemoveModelPort(ollamaModelName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No running container found for model {Model}", ollamaModelName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var containerName = SanitizeContainerName($"ngino-llamacpp-{ollamaModelName}");
|
||||||
|
|
||||||
|
_logger.LogInformation("Stopping llama.cpp container {ContainerName}", containerName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunDockerAsync(["stop", "--time", "10", containerName], cancellationToken);
|
||||||
|
await RunDockerAsync(["rm", "-f", containerName], cancellationToken);
|
||||||
|
ClearModelFallback(ollamaModelName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to stop container {ContainerName}", containerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAllContainersAsync()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping all llama.cpp containers...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (exitCode, output) = await RunDockerWithOutputAsync(
|
||||||
|
["ps", "-q", "--filter", $"label={NginoContainerLabel}"],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
if (exitCode == 0 && !string.IsNullOrWhiteSpace(output))
|
||||||
|
{
|
||||||
|
var containerIds = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
foreach (var id in containerIds)
|
||||||
|
{
|
||||||
|
await RunDockerAsync(["stop", "--time", "10", id], CancellationToken.None);
|
||||||
|
await RunDockerAsync(["rm", "-f", id], CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to stop all llama.cpp containers");
|
||||||
|
}
|
||||||
|
|
||||||
|
_modelPorts.Clear();
|
||||||
|
_reservedPorts.Clear();
|
||||||
|
_fallbackModels.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TestDockerAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (exitCode, _) = await RunDockerWithOutputAsync(["info", "--format", "{{.ServerVersion}}"], CancellationToken.None);
|
||||||
|
return exitCode == 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopAllContainersAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LlamaCppModel? ParseManifest(string manifestPath)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(manifestPath);
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("layers", out var layers) || layers.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? modelDigest = null;
|
||||||
|
foreach (var layer in layers.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (layer.TryGetProperty("mediaType", out var mediaType)
|
||||||
|
&& mediaType.GetString() == "application/vnd.ollama.image.model"
|
||||||
|
&& layer.TryGetProperty("digest", out var digest))
|
||||||
|
{
|
||||||
|
modelDigest = digest.GetString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(modelDigest))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelName = ResolveModelName(manifestPath);
|
||||||
|
if (modelName is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var blobName = modelDigest.Replace(":", "-", StringComparison.Ordinal);
|
||||||
|
var blobPath = Path.GetFullPath(Path.Combine(_blobsPath, blobName));
|
||||||
|
|
||||||
|
return new LlamaCppModel
|
||||||
|
{
|
||||||
|
OllamaName = modelName,
|
||||||
|
BlobDigest = blobName,
|
||||||
|
BlobPath = blobPath,
|
||||||
|
ManifestPath = manifestPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveModelName(string manifestPath)
|
||||||
|
{
|
||||||
|
var normalizedPath = manifestPath.Replace('\\', '/');
|
||||||
|
var parts = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
var manifestIndex = Array.FindLastIndex(parts, p =>
|
||||||
|
p.Equals("manifests", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (manifestIndex < 0 || manifestIndex >= parts.Length - 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativeParts = parts[(manifestIndex + 1)..];
|
||||||
|
|
||||||
|
if (relativeParts.Length < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry = relativeParts[0];
|
||||||
|
|
||||||
|
if (registry.Equals("registry.ollama.ai", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (relativeParts.Length < 3)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativeParts[1].Equals("library", StringComparison.OrdinalIgnoreCase) && relativeParts.Length >= 4)
|
||||||
|
{
|
||||||
|
return $"{relativeParts[2]}:{relativeParts[3]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativeParts.Length == 3)
|
||||||
|
{
|
||||||
|
return $"{relativeParts[1]}:{relativeParts[2]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativeParts.Length >= 3)
|
||||||
|
{
|
||||||
|
var tag = relativeParts[^1];
|
||||||
|
var modelPath = string.Join("/", relativeParts.Take(relativeParts.Length - 1));
|
||||||
|
return $"{modelPath}:{tag}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] BuildDockerRunArgs(string containerName, LlamaCppModel model, int port)
|
||||||
|
{
|
||||||
|
var blobsDir = Path.GetDirectoryName(Path.GetFullPath(model.BlobPath))!;
|
||||||
|
var blobFile = Path.GetFileName(model.BlobPath);
|
||||||
|
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--label", $"{NginoContainerLabel}=true",
|
||||||
|
"--name", containerName,
|
||||||
|
"-p", $"{port}:{port}",
|
||||||
|
"-v", $"{blobsDir}:/models/blobs:ro",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (HasRocmDevices())
|
||||||
|
{
|
||||||
|
args.Add("--device=/dev/kfd");
|
||||||
|
args.Add("--device=/dev/dri");
|
||||||
|
args.Add("--group-add=video");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasNvidiaGpu() && !HasRocmDevices())
|
||||||
|
{
|
||||||
|
args.Add("--gpus=all");
|
||||||
|
}
|
||||||
|
|
||||||
|
args.Add(_dockerImage);
|
||||||
|
args.Add("--embeddings");
|
||||||
|
args.Add("-m");
|
||||||
|
args.Add($"/models/blobs/{blobFile}");
|
||||||
|
args.Add("-ngl");
|
||||||
|
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");
|
||||||
|
args.Add(port.ToString());
|
||||||
|
|
||||||
|
return [.. args];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int?> 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()
|
||||||
|
{
|
||||||
|
lock (_portAllocationLock)
|
||||||
|
{
|
||||||
|
var usedPorts = new HashSet<int>(_modelPorts.Values);
|
||||||
|
foreach (var reservedPort in _reservedPorts.Keys)
|
||||||
|
{
|
||||||
|
usedPorts.Add(reservedPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
var port = _basePort;
|
||||||
|
|
||||||
|
while (usedPorts.Contains(port))
|
||||||
|
{
|
||||||
|
port++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reservedPorts[port] = 0;
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ContainerStartResult> WaitForServerReadyAsync(
|
||||||
|
string host, int port, string containerName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + ContainerStartTimeout;
|
||||||
|
|
||||||
|
var tcpResult = await WaitForTcpPortAsync(host, port, containerName, deadline, cancellationToken);
|
||||||
|
if (tcpResult != ContainerStartResult.Ready)
|
||||||
|
{
|
||||||
|
return tcpResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var handler = new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(3),
|
||||||
|
UseProxy = false
|
||||||
|
};
|
||||||
|
using var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
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 ContainerStartResult.Ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_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 ContainerStartResult.TimedOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ContainerStartResult> 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 ContainerStartResult.Ready;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContainerStartResult.TimedOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> 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)
|
||||||
|
{
|
||||||
|
var sanitized = InvalidContainerNameChars().Replace(name, "_");
|
||||||
|
return sanitized.Trim('_').ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> RunDockerAsync(string[] args, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (exitCode, _) = await RunDockerWithOutputAsync(args, cancellationToken);
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int ExitCode, string Output)> RunDockerWithOutputAsync(
|
||||||
|
string[] args, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "docker",
|
||||||
|
Arguments = string.Join(" ", args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a)),
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
|
||||||
|
var readOutput = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||||
|
var readError = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||||
|
var waitTask = process.WaitForExitAsync(cancellationToken);
|
||||||
|
|
||||||
|
var completed = await Task.WhenAny(waitTask, Task.Delay(DockerTimeout, cancellationToken));
|
||||||
|
|
||||||
|
string output;
|
||||||
|
string error;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
output = await readOutput;
|
||||||
|
error = await readError;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
output = "";
|
||||||
|
error = "timed out";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completed != waitTask)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Docker command timed out: docker {Args}", string.Join(" ", args));
|
||||||
|
try { process.Kill(entireProcessTree: true); } catch { }
|
||||||
|
return (-1, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(error))
|
||||||
|
{
|
||||||
|
output = $"{output}\n{error}".Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (process.ExitCode, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultDockerImage()
|
||||||
|
{
|
||||||
|
if (HasRocmDevices())
|
||||||
|
{
|
||||||
|
return "ghcr.io/ggml-org/llama.cpp:server-rocm";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasNvidiaGpu())
|
||||||
|
{
|
||||||
|
return "ghcr.io/ggml-org/llama.cpp:server-cuda";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ghcr.io/ggml-org/llama.cpp:server";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasRocmDevices() => File.Exists("/dev/kfd") && Directory.Exists("/dev/dri");
|
||||||
|
|
||||||
|
private static bool HasNvidiaGpu()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.Exists("/proc/driver/nvidia/version")
|
||||||
|
|| Directory.Exists("/proc/driver/nvidia/gpus");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[^a-zA-Z0-9_.-]")]
|
||||||
|
private static partial Regex InvalidContainerNameChars();
|
||||||
|
|
||||||
|
private enum ContainerStartResult
|
||||||
|
{
|
||||||
|
Ready,
|
||||||
|
ContainerExited,
|
||||||
|
TimedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record LlamaCppModel
|
||||||
|
{
|
||||||
|
public required string OllamaName { get; init; }
|
||||||
|
public required string BlobDigest { get; init; }
|
||||||
|
public required string BlobPath { get; init; }
|
||||||
|
public required string ManifestPath { get; init; }
|
||||||
|
}
|
||||||
@@ -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<string, object?>();
|
||||||
|
|
||||||
|
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<string, object?>();
|
||||||
|
|
||||||
|
if (root.TryGetProperty("messages", out var messages))
|
||||||
|
result["messages"] = messages.Deserialize<object>(JsonOptions);
|
||||||
|
|
||||||
|
result["stream"] = true;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("model", out var model))
|
||||||
|
result["model"] = model.GetString();
|
||||||
|
|
||||||
|
CopyOptions(root, result, chat: true);
|
||||||
|
|
||||||
|
if (!result.ContainsKey("max_tokens"))
|
||||||
|
result["max_tokens"] = 2048;
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ExtractOriginalStream(byte[] originalBody, string pathAndQuery)
|
||||||
|
{
|
||||||
|
var path = pathAndQuery.Split('?')[0];
|
||||||
|
if (path is not "/api/generate" and not "/api/chat")
|
||||||
|
return true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(originalBody);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.TryGetProperty("stream", out var stream))
|
||||||
|
return stream.ValueKind != JsonValueKind.False;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] TranslateEmbedBody(byte[] body)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["model"] = _modelName
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.TryGetProperty("input", out var input))
|
||||||
|
result["input"] = input.Deserialize<object>(JsonOptions);
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Func<HttpResponseMessage, CancellationToken, Task> CreateResponseHandler(
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
string originalPath,
|
||||||
|
Func<bool> originalRequestedStream) =>
|
||||||
|
async (response, ct) =>
|
||||||
|
{
|
||||||
|
await TranslateAndSendResponse(response, sendAsync, requestId, originalPath, originalRequestedStream(), ct);
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task TranslateAndSendResponse(
|
||||||
|
HttpResponseMessage httpResponse,
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
string originalPath,
|
||||||
|
bool originalRequestedStream,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = originalPath.Split('?')[0];
|
||||||
|
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case "/api/tags":
|
||||||
|
await SynthesizeTagsResponse(sendAsync, requestId, cancellationToken);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "/api/ps":
|
||||||
|
await SynthesizePsResponse(sendAsync, requestId, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!httpResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await ForwardRawResponse(httpResponse, sendAsync, requestId, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path is "/api/generate" or "/api/chat")
|
||||||
|
{
|
||||||
|
if (originalRequestedStream)
|
||||||
|
{
|
||||||
|
await TranslateStreaming(path, httpResponse, sendAsync, requestId, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await TranslateNonStreaming(path, httpResponse, sendAsync, requestId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ForwardRawResponse(httpResponse, sendAsync, requestId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ForwardRawResponse(
|
||||||
|
HttpResponseMessage httpResponse,
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||||
|
RequestId = requestId,
|
||||||
|
StatusCode = (int)httpResponse.StatusCode,
|
||||||
|
ReasonPhrase = httpResponse.ReasonPhrase
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
var buffer = new byte[64 * 1024];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var bytesRead = await stream.ReadAsync(buffer, cancellationToken);
|
||||||
|
if (bytesRead == 0)
|
||||||
|
break;
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseBody,
|
||||||
|
RequestId = requestId,
|
||||||
|
Body = buffer.AsSpan(0, bytesRead).ToArray()
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
|
RequestId = requestId
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SynthesizeTagsResponse(
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var modelList = new
|
||||||
|
{
|
||||||
|
models = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = _modelName,
|
||||||
|
model = _modelName,
|
||||||
|
modified_at = DateTime.UtcNow.ToString("o"),
|
||||||
|
size = 0L,
|
||||||
|
digest = "sha256:" + _modelName,
|
||||||
|
details = new
|
||||||
|
{
|
||||||
|
format = "gguf",
|
||||||
|
family = "llama",
|
||||||
|
parameter_size = "",
|
||||||
|
quantization_level = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var body = JsonSerializer.SerializeToUtf8Bytes(modelList, JsonOptions);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||||
|
RequestId = requestId,
|
||||||
|
StatusCode = 200,
|
||||||
|
ReasonPhrase = "OK"
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseBody,
|
||||||
|
RequestId = requestId,
|
||||||
|
Body = body
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
|
RequestId = requestId
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SynthesizePsResponse(
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var body = JsonSerializer.SerializeToUtf8Bytes(new { models = Array.Empty<object>() }, JsonOptions);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||||
|
RequestId = requestId,
|
||||||
|
StatusCode = 200,
|
||||||
|
ReasonPhrase = "OK"
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseBody,
|
||||||
|
RequestId = requestId,
|
||||||
|
Body = body
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
|
RequestId = requestId
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TranslateNonStreaming(
|
||||||
|
string path,
|
||||||
|
HttpResponseMessage httpResponse,
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||||
|
RequestId = requestId,
|
||||||
|
StatusCode = (int)httpResponse.StatusCode,
|
||||||
|
ReasonPhrase = httpResponse.ReasonPhrase
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
var body = await httpResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
byte[] translatedBody;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
translatedBody = path switch
|
||||||
|
{
|
||||||
|
"/api/generate" => TranslateNonStreamingGenerate(body),
|
||||||
|
"/api/chat" => TranslateNonStreamingChat(body),
|
||||||
|
_ => body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to translate non-streaming response");
|
||||||
|
translatedBody = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseBody,
|
||||||
|
RequestId = requestId,
|
||||||
|
Body = translatedBody
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
|
RequestId = requestId
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TranslateStreaming(
|
||||||
|
string path,
|
||||||
|
HttpResponseMessage httpResponse,
|
||||||
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
|
string requestId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseHeaders,
|
||||||
|
RequestId = requestId,
|
||||||
|
StatusCode = 200,
|
||||||
|
ReasonPhrase = "OK"
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var line = await reader.ReadLineAsync(cancellationToken);
|
||||||
|
if (line is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (!line.StartsWith("data: ", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var jsonStr = line[6..];
|
||||||
|
if (jsonStr == "[DONE]")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(jsonStr);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
byte[]? chunk = path switch
|
||||||
|
{
|
||||||
|
"/api/generate" => TranslateGenerateStreamChunk(root),
|
||||||
|
"/api/chat" => TranslateChatStreamChunk(root),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chunk is not null)
|
||||||
|
{
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseBody,
|
||||||
|
RequestId = requestId,
|
||||||
|
Body = chunk
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseBody,
|
||||||
|
RequestId = requestId,
|
||||||
|
Body = Encoding.UTF8.GetBytes(
|
||||||
|
$"{{\"model\":\"{EscapeJson(_modelName)}\",\"response\":\"\",\"done\":true}}\n")
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
await sendAsync(new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
|
RequestId = requestId
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] TranslateNonStreamingGenerate(byte[] body)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["model"] = _modelName,
|
||||||
|
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||||
|
["response"] = root.TryGetProperty("content", out var content) ? content.GetString() : "",
|
||||||
|
["done"] = root.TryGetProperty("stop", out var stop) && stop.GetBoolean()
|
||||||
|
};
|
||||||
|
|
||||||
|
CopyTimings(root, result);
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] TranslateNonStreamingChat(byte[] body)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var message = root.GetProperty("choices")[0].GetProperty("message");
|
||||||
|
var content = message.GetProperty("content").GetString() ?? "";
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["model"] = _modelName,
|
||||||
|
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||||
|
["message"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = "assistant",
|
||||||
|
["content"] = content
|
||||||
|
},
|
||||||
|
["done"] = true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.TryGetProperty("usage", out var usage))
|
||||||
|
{
|
||||||
|
if (usage.TryGetProperty("completion_tokens", out var comp))
|
||||||
|
result["eval_count"] = comp.GetInt32();
|
||||||
|
if (usage.TryGetProperty("prompt_tokens", out var prompt))
|
||||||
|
result["prompt_eval_count"] = prompt.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyTimings(root, result);
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? TranslateGenerateStreamChunk(JsonElement root)
|
||||||
|
{
|
||||||
|
var done = root.TryGetProperty("stop", out var stop) && stop.GetBoolean();
|
||||||
|
var text = root.TryGetProperty("content", out var content) ? content.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["model"] = _modelName,
|
||||||
|
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||||
|
["response"] = text,
|
||||||
|
["done"] = done
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done)
|
||||||
|
CopyTimings(root, result);
|
||||||
|
|
||||||
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||||
|
var withNewline = new byte[bytes.Length + 1];
|
||||||
|
bytes.CopyTo(withNewline, 0);
|
||||||
|
withNewline[^1] = (byte)'\n';
|
||||||
|
return withNewline;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? TranslateChatStreamChunk(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var choice = choices[0];
|
||||||
|
var delta = choice.GetProperty("delta");
|
||||||
|
var finishReason = choice.TryGetProperty("finish_reason", out var fr) ? fr.GetString() : null;
|
||||||
|
var done = finishReason is not null && finishReason != "null" && finishReason != "";
|
||||||
|
|
||||||
|
var content = delta.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||||
|
var role = delta.TryGetProperty("role", out var r) ? r.GetString() : null;
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["model"] = _modelName,
|
||||||
|
["created_at"] = DateTime.UtcNow.ToString("o"),
|
||||||
|
["message"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["role"] = role ?? "assistant",
|
||||||
|
["content"] = content
|
||||||
|
},
|
||||||
|
["done"] = done
|
||||||
|
};
|
||||||
|
|
||||||
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(result, JsonOptions);
|
||||||
|
var withNewline = new byte[bytes.Length + 1];
|
||||||
|
bytes.CopyTo(withNewline, 0);
|
||||||
|
withNewline[^1] = (byte)'\n';
|
||||||
|
return withNewline;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyOptions(JsonElement root, Dictionary<string, object?> target, bool chat = false)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("options", out var options) || options.ValueKind != JsonValueKind.Object)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var opt in options.EnumerateObject())
|
||||||
|
{
|
||||||
|
var key = (chat ? MapChatOptionName(opt.Name) : MapGenerateOptionName(opt.Name)) ?? opt.Name;
|
||||||
|
target[key] = ValueToObject(opt.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? MapGenerateOptionName(string name) => name switch
|
||||||
|
{
|
||||||
|
"num_predict" => "n_predict",
|
||||||
|
"temperature" => "temperature",
|
||||||
|
"top_p" => "top_p",
|
||||||
|
"top_k" => "top_k",
|
||||||
|
"seed" => "seed",
|
||||||
|
"stop" => "stop",
|
||||||
|
"repeat_penalty" => "repeat_penalty",
|
||||||
|
"repeat_last_n" => "repeat_last_n",
|
||||||
|
"frequency_penalty" => "frequency_penalty",
|
||||||
|
"presence_penalty" => "presence_penalty",
|
||||||
|
"mirostat" => "mirostat",
|
||||||
|
"mirostat_tau" => "mirostat_tau",
|
||||||
|
"mirostat_eta" => "mirostat_eta",
|
||||||
|
"num_ctx" => "n_ctx",
|
||||||
|
"num_batch" => "n_batch",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string? MapChatOptionName(string name) => name switch
|
||||||
|
{
|
||||||
|
"num_predict" => "max_tokens",
|
||||||
|
"temperature" => "temperature",
|
||||||
|
"top_p" => "top_p",
|
||||||
|
"seed" => "seed",
|
||||||
|
"stop" => "stop",
|
||||||
|
"frequency_penalty" => "frequency_penalty",
|
||||||
|
"presence_penalty" => "presence_penalty",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void CopyTimings(JsonElement root, Dictionary<string, object?> target)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("timings", out var timings))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (timings.TryGetProperty("predicted_n", out var predN))
|
||||||
|
target["eval_count"] = predN.GetInt32();
|
||||||
|
if (timings.TryGetProperty("predicted_ms", out var predMs))
|
||||||
|
target["eval_duration"] = (long)(predMs.GetDouble() * 1_000_000);
|
||||||
|
if (timings.TryGetProperty("prompt_n", out var promptN))
|
||||||
|
target["prompt_eval_count"] = promptN.GetInt32();
|
||||||
|
if (timings.TryGetProperty("prompt_ms", out var promptMs))
|
||||||
|
target["prompt_eval_duration"] = (long)(promptMs.GetDouble() * 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ValueToObject(JsonElement element)
|
||||||
|
{
|
||||||
|
return element.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => element.GetString(),
|
||||||
|
JsonValueKind.Number => element.TryGetInt64(out var l) ? (object)l : element.GetDouble(),
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.Null => null,
|
||||||
|
_ => element.Deserialize<object>(JsonOptions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeJson(string value) =>
|
||||||
|
value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
@@ -16,6 +16,21 @@ try
|
|||||||
Console.WriteLine(" WARNING: server TLS certificate validation is disabled");
|
Console.WriteLine(" WARNING: server TLS certificate validation is disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.UseLlamaCppViaDocker)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" llama.cpp via Docker: enabled");
|
||||||
|
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.
|
// Args are parsed by ClientOptions; keep them away from the host configuration.
|
||||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] });
|
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] });
|
||||||
builder.Services.AddSingleton(options);
|
builder.Services.AddSingleton(options);
|
||||||
@@ -24,6 +39,7 @@ try
|
|||||||
builder.Services.AddWindowsService(service => service.ServiceName = "NginoClient");
|
builder.Services.AddWindowsService(service => service.ServiceName = "NginoClient");
|
||||||
// The EventLog provider defaults to Warning; connection state is worth seeing there.
|
// The EventLog provider defaults to Warning; connection state is worth seeing there.
|
||||||
builder.Logging.AddFilter<Microsoft.Extensions.Logging.EventLog.EventLogLoggerProvider>("Ngino.Client", LogLevel.Information);
|
builder.Logging.AddFilter<Microsoft.Extensions.Logging.EventLog.EventLogLoggerProvider>("Ngino.Client", LogLevel.Information);
|
||||||
|
builder.Logging.AddProvider(new FileLoggerProvider(logDirectory));
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ internal sealed class TunnelClient
|
|||||||
private const string EmbeddingWarmupInput = "Ngino warmup";
|
private const string EmbeddingWarmupInput = "Ngino warmup";
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, UpstreamRequest> _activeRequests = new();
|
private readonly ConcurrentDictionary<string, UpstreamRequest> _activeRequests = new();
|
||||||
|
private readonly ConcurrentDictionary<string, PendingRequestBody> _pendingRequestBodies = new();
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ClientOptions _options;
|
private readonly ClientOptions _options;
|
||||||
private readonly ILogger<TunnelClient> _logger;
|
private readonly ILogger<TunnelClient> _logger;
|
||||||
private readonly object _modelSnapshotLock = new();
|
private readonly object _modelSnapshotLock = new();
|
||||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
|
private readonly LlamaCppManager? _llamaCppManager;
|
||||||
private List<string> _lastActiveModels = [];
|
private List<string> _lastActiveModels = [];
|
||||||
private List<string> _lastModels = [];
|
private List<string> _lastModels = [];
|
||||||
|
|
||||||
@@ -32,10 +34,41 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
Timeout = Timeout.InfiniteTimeSpan
|
Timeout = Timeout.InfiniteTimeSpan
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (_options.UseLlamaCppViaDocker)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.UseOllamaModelsPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"--use-ollama-models-path is required when --use-llama-cpp-via-docker is set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_llamaCppManager = new LlamaCppManager(
|
||||||
|
_options.UseOllamaModelsPath,
|
||||||
|
_options.LlamaCppDockerImage,
|
||||||
|
_options.LlamaCppBasePort,
|
||||||
|
_logger,
|
||||||
|
_options.LlamaCppFallbackCooldown,
|
||||||
|
_options.LlamaCppParallel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(CancellationToken cancellationToken)
|
public async Task RunAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (_llamaCppManager is not null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Testing Docker availability...");
|
||||||
|
var dockerAvailable = await _llamaCppManager.TestDockerAsync();
|
||||||
|
if (!dockerAvailable)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Docker is not available. llama.cpp via Docker will not work.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Docker is available. Using llama.cpp image: {Image}", _llamaCppManager.DockerImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
using var socket = new ClientWebSocket();
|
using var socket = new ClientWebSocket();
|
||||||
@@ -154,6 +187,16 @@ internal sealed class TunnelClient
|
|||||||
|
|
||||||
private async Task<List<string>> GetUpstreamModelsAsync(CancellationToken cancellationToken)
|
private async Task<List<string>> GetUpstreamModelsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (_llamaCppManager is not null)
|
||||||
|
{
|
||||||
|
var models = _llamaCppManager.DiscoverModelsWithBlob();
|
||||||
|
return models
|
||||||
|
.Select(m => m.OllamaName)
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/tags"));
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/tags"));
|
||||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -166,6 +209,17 @@ internal sealed class TunnelClient
|
|||||||
|
|
||||||
private async Task<List<string>> GetActiveUpstreamModelsAsync(CancellationToken cancellationToken)
|
private async Task<List<string>> GetActiveUpstreamModelsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (_llamaCppManager is not null)
|
||||||
|
{
|
||||||
|
var models = _llamaCppManager.DiscoverModelsWithBlob();
|
||||||
|
return models
|
||||||
|
.Where(m => _llamaCppManager.IsModelActive(m.OllamaName))
|
||||||
|
.Select(m => m.OllamaName)
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/ps"));
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_options.Upstream, "/api/ps"));
|
||||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
|
||||||
@@ -290,7 +344,8 @@ internal sealed class TunnelClient
|
|||||||
switch (message.Type)
|
switch (message.Type)
|
||||||
{
|
{
|
||||||
case TunnelMessageTypes.HttpRequest:
|
case TunnelMessageTypes.HttpRequest:
|
||||||
StartRequest(socket, message, cancellationToken);
|
_pendingRequestBodies[message.RequestId] = new PendingRequestBody();
|
||||||
|
_ = Task.Run(() => StartRequest(socket, message, cancellationToken), cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.HttpRequestBody:
|
case TunnelMessageTypes.HttpRequestBody:
|
||||||
@@ -298,6 +353,10 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
requestWithBody.AddBody(message.Body ?? []);
|
requestWithBody.AddBody(message.Body ?? []);
|
||||||
}
|
}
|
||||||
|
else if (_pendingRequestBodies.TryGetValue(message.RequestId, out var pendingBody))
|
||||||
|
{
|
||||||
|
pendingBody.AddBody(message.Body ?? []);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.HttpRequestComplete:
|
case TunnelMessageTypes.HttpRequestComplete:
|
||||||
@@ -305,6 +364,10 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
completedRequest.CompleteBody();
|
completedRequest.CompleteBody();
|
||||||
}
|
}
|
||||||
|
else if (_pendingRequestBodies.TryGetValue(message.RequestId, out var pendingBody))
|
||||||
|
{
|
||||||
|
pendingBody.Complete();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.Cancel:
|
case TunnelMessageTypes.Cancel:
|
||||||
@@ -312,6 +375,10 @@ internal sealed class TunnelClient
|
|||||||
{
|
{
|
||||||
cancelledRequest.Cancel();
|
cancelledRequest.Cancel();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_pendingRequestBodies.TryRemove(message.RequestId, out _);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TunnelMessageTypes.ModelCommand:
|
case TunnelMessageTypes.ModelCommand:
|
||||||
@@ -363,6 +430,11 @@ internal sealed class TunnelClient
|
|||||||
throw new InvalidOperationException("Model command is missing a model name.");
|
throw new InvalidOperationException("Model command is missing a model name.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_llamaCppManager is not null)
|
||||||
|
{
|
||||||
|
return await ExecuteModelCommandWithLlamaCppAsync(message, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
using var request = BuildModelCommandRequest(message);
|
using var request = BuildModelCommandRequest(message);
|
||||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
@@ -379,6 +451,126 @@ internal sealed class TunnelClient
|
|||||||
return BuildModelCommandResult(message.RequestId, response, body);
|
return BuildModelCommandResult(message.RequestId, response, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<TunnelMessage> ExecuteModelCommandWithLlamaCppAsync(
|
||||||
|
TunnelMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var modelName = message.Model?.Trim();
|
||||||
|
var normalizedCommand = NormalizeModelCommand(message.Command);
|
||||||
|
|
||||||
|
switch (normalizedCommand)
|
||||||
|
{
|
||||||
|
case "load":
|
||||||
|
{
|
||||||
|
var models = _llamaCppManager!.DiscoverModelsWithBlob();
|
||||||
|
var model = models.FirstOrDefault(m =>
|
||||||
|
string.Equals(m.OllamaName, modelName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.ModelCommandResult,
|
||||||
|
RequestId = message.RequestId,
|
||||||
|
StatusCode = 404,
|
||||||
|
Error = $"Model '{modelName}' not found in Ollama models path."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var started = await _llamaCppManager.StartModelContainerAsync(model, cancellationToken);
|
||||||
|
if (!started)
|
||||||
|
{
|
||||||
|
_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", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "unload":
|
||||||
|
{
|
||||||
|
var stopped = await _llamaCppManager!.StopModelContainerAsync(modelName!, cancellationToken);
|
||||||
|
if (!stopped)
|
||||||
|
{
|
||||||
|
_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", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pull":
|
||||||
|
case "delete":
|
||||||
|
{
|
||||||
|
using var request = BuildModelCommandRequest(_options.Upstream, message.Command, message.Model);
|
||||||
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
return BuildModelCommandResult(message.RequestId, response, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "show":
|
||||||
|
{
|
||||||
|
var models = _llamaCppManager!.DiscoverModelsWithBlob();
|
||||||
|
var model = models.FirstOrDefault(m =>
|
||||||
|
string.Equals(m.OllamaName, modelName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.ModelCommandResult,
|
||||||
|
RequestId = message.RequestId,
|
||||||
|
StatusCode = 404,
|
||||||
|
Error = $"Model '{modelName}' not found in Ollama models path."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var showResponse = new
|
||||||
|
{
|
||||||
|
modelfile = $"# llama.cpp via Docker\nFROM {model.BlobDigest}\n",
|
||||||
|
details = new
|
||||||
|
{
|
||||||
|
format = "gguf",
|
||||||
|
family = "llama",
|
||||||
|
parameter_size = "",
|
||||||
|
quantization_level = ""
|
||||||
|
},
|
||||||
|
model_info = new { }
|
||||||
|
};
|
||||||
|
|
||||||
|
var body = JsonSerializer.SerializeToUtf8Bytes(showResponse, JsonOptions);
|
||||||
|
return new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.ModelCommandResult,
|
||||||
|
RequestId = message.RequestId,
|
||||||
|
StatusCode = 200,
|
||||||
|
ReasonPhrase = "OK",
|
||||||
|
Body = body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unsupported model command '{message.Command}' with llama.cpp.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static TunnelMessage BuildModelCommandResult(
|
private static TunnelMessage BuildModelCommandResult(
|
||||||
string requestId,
|
string requestId,
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
@@ -394,6 +586,19 @@ internal sealed class TunnelClient
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TunnelMessage BuildModelCommandResult(
|
||||||
|
string requestId, int statusCode, string reasonPhrase, byte[] body)
|
||||||
|
{
|
||||||
|
return new TunnelMessage
|
||||||
|
{
|
||||||
|
Type = TunnelMessageTypes.ModelCommandResult,
|
||||||
|
RequestId = requestId,
|
||||||
|
StatusCode = statusCode,
|
||||||
|
ReasonPhrase = reasonPhrase,
|
||||||
|
Body = body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ShouldRetryModelCommandWithEmbedding(
|
private static bool ShouldRetryModelCommandWithEmbedding(
|
||||||
string? command,
|
string? command,
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
@@ -489,15 +694,122 @@ internal sealed class TunnelClient
|
|||||||
private static StringContent JsonContent<T>(T value) =>
|
private static StringContent JsonContent<T>(T value) =>
|
||||||
new(JsonSerializer.Serialize(value, JsonOptions), Encoding.UTF8, "application/json");
|
new(JsonSerializer.Serialize(value, JsonOptions), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
private void StartRequest(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
private async Task StartRequest(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
Uri? effectiveUpstream = null;
|
||||||
|
var modelName = UpstreamRequest.ExtractModelName(message);
|
||||||
|
|
||||||
|
if (_llamaCppManager is not null)
|
||||||
|
{
|
||||||
|
if (modelName is not null)
|
||||||
|
{
|
||||||
|
effectiveUpstream = _llamaCppManager.GetUpstream(modelName);
|
||||||
|
if (effectiveUpstream is not null)
|
||||||
|
{
|
||||||
|
var running = await _llamaCppManager.IsContainerRunningAsync(modelName);
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Cached llama.cpp container for model '{Model}' is not running anymore. Starting a fresh one on demand...",
|
||||||
|
modelName);
|
||||||
|
effectiveUpstream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveUpstream is null)
|
||||||
|
{
|
||||||
|
var model = _llamaCppManager.DiscoverModelsWithBlob()
|
||||||
|
.FirstOrDefault(m => string.Equals(m.OllamaName, modelName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (model is not null)
|
||||||
|
{
|
||||||
|
if (_llamaCppManager.IsModelOnFallback(modelName))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Model '{Model}' previously failed to load via llama.cpp. Routing directly to the Ollama upstream.",
|
||||||
|
modelName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_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
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Model '{Model}' was not found in the Ollama models path. Falling back to default upstream.",
|
||||||
|
modelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When llama.cpp backend is active, set up request/response translation
|
||||||
|
Func<string, string?>? pathTransform = null;
|
||||||
|
Func<byte[], byte[]>? bodyTransform = null;
|
||||||
|
Func<HttpResponseMessage, CancellationToken, Task>? responseHandler = null;
|
||||||
|
string? translatorModelName = null;
|
||||||
|
|
||||||
|
if (_llamaCppManager is not null && effectiveUpstream is not null && modelName is not null)
|
||||||
|
{
|
||||||
|
var translator = new OllamaToLlamaCppTranslator(modelName, _logger);
|
||||||
|
translatorModelName = modelName;
|
||||||
|
bool originalRequestedStream = true;
|
||||||
|
|
||||||
|
pathTransform = path =>
|
||||||
|
{
|
||||||
|
if (translator.TryTranslatePath(message.Method ?? "GET", path, out var newPath))
|
||||||
|
{
|
||||||
|
return newPath;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
bodyTransform = body =>
|
||||||
|
{
|
||||||
|
originalRequestedStream = OllamaToLlamaCppTranslator.ExtractOriginalStream(body, message.PathAndQuery ?? "/");
|
||||||
|
return translator.TranslateBody(message.PathAndQuery ?? "/", body);
|
||||||
|
};
|
||||||
|
|
||||||
|
responseHandler = translator.CreateResponseHandler(
|
||||||
|
(response, token) => SendAsync(socket, response, token),
|
||||||
|
message.RequestId,
|
||||||
|
message.PathAndQuery ?? "/",
|
||||||
|
() => originalRequestedStream);
|
||||||
|
}
|
||||||
|
|
||||||
var request = new UpstreamRequest(
|
var request = new UpstreamRequest(
|
||||||
_options,
|
_options,
|
||||||
_httpClient,
|
_httpClient,
|
||||||
message,
|
message,
|
||||||
(response, token) => SendAsync(socket, response, token),
|
(response, token) => SendAsync(socket, response, token),
|
||||||
requestId => _activeRequests.TryRemove(requestId, out _),
|
requestId => _activeRequests.TryRemove(requestId, out _),
|
||||||
cancellationToken);
|
cancellationToken,
|
||||||
|
effectiveUpstream: effectiveUpstream,
|
||||||
|
responseHandler: responseHandler,
|
||||||
|
pathTransform: pathTransform,
|
||||||
|
bodyTransform: bodyTransform,
|
||||||
|
onConnectionRefused: () =>
|
||||||
|
{
|
||||||
|
if (_llamaCppManager is not null && modelName is not null)
|
||||||
|
{
|
||||||
|
_llamaCppManager.RemoveModelMapping(modelName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!_activeRequests.TryAdd(message.RequestId, request))
|
if (!_activeRequests.TryAdd(message.RequestId, request))
|
||||||
{
|
{
|
||||||
@@ -510,10 +822,16 @@ internal sealed class TunnelClient
|
|||||||
Error = "Duplicate request id."
|
Error = "Duplicate request id."
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
_pendingRequestBodies.TryRemove(message.RequestId, out _);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = Task.Run(request.RunAsync, cancellationToken);
|
if (_pendingRequestBodies.TryRemove(message.RequestId, out var pendingBody))
|
||||||
|
{
|
||||||
|
pendingBody.TransferTo(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
await request.RunAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendAsync(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
private async Task SendAsync(ClientWebSocket socket, TunnelMessage message, CancellationToken cancellationToken)
|
||||||
@@ -541,5 +859,51 @@ internal sealed class TunnelClient
|
|||||||
request.Cancel();
|
request.Cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_pendingRequestBodies.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PendingRequestBody
|
||||||
|
{
|
||||||
|
private readonly List<byte[]> _chunks = [];
|
||||||
|
private bool _completed;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public void AddBody(byte[] chunk)
|
||||||
|
{
|
||||||
|
if (chunk.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_chunks.Add(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Complete()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_completed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TransferTo(UpstreamRequest request)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var chunk in _chunks)
|
||||||
|
{
|
||||||
|
request.AddBody(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
_chunks.Clear();
|
||||||
|
|
||||||
|
if (_completed)
|
||||||
|
{
|
||||||
|
request.CompleteBody();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Sockets;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Ngino.Protocol;
|
using Ngino.Protocol;
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ internal sealed class UpstreamRequest
|
|||||||
"Trailer",
|
"Trailer",
|
||||||
"Transfer-Encoding",
|
"Transfer-Encoding",
|
||||||
"Upgrade",
|
"Upgrade",
|
||||||
ProtocolConstants.TokenHeader
|
ProtocolConstants.TokenHeader,
|
||||||
|
ProtocolConstants.ModelHeader
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||||
@@ -33,8 +35,13 @@ internal sealed class UpstreamRequest
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly TunnelMessage _initialMessage;
|
private readonly TunnelMessage _initialMessage;
|
||||||
private readonly Action<string> _onComplete;
|
private readonly Action<string> _onComplete;
|
||||||
private readonly ClientOptions _options;
|
private readonly Uri _upstream;
|
||||||
private readonly Func<TunnelMessage, CancellationToken, Task> _sendAsync;
|
private readonly Func<TunnelMessage, CancellationToken, Task> _sendAsync;
|
||||||
|
private readonly Func<HttpResponseMessage, CancellationToken, Task>? _responseHandler;
|
||||||
|
private readonly Func<string, string?>? _pathTransform;
|
||||||
|
private readonly Func<byte[], byte[]>? _bodyTransform;
|
||||||
|
private readonly Action? _onConnectionRefused;
|
||||||
|
private readonly List<byte[]> _bufferedBody = [];
|
||||||
|
|
||||||
public UpstreamRequest(
|
public UpstreamRequest(
|
||||||
ClientOptions options,
|
ClientOptions options,
|
||||||
@@ -42,13 +49,22 @@ internal sealed class UpstreamRequest
|
|||||||
TunnelMessage initialMessage,
|
TunnelMessage initialMessage,
|
||||||
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
Func<TunnelMessage, CancellationToken, Task> sendAsync,
|
||||||
Action<string> onComplete,
|
Action<string> onComplete,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
Uri? effectiveUpstream = null,
|
||||||
|
Func<HttpResponseMessage, CancellationToken, Task>? responseHandler = null,
|
||||||
|
Func<string, string?>? pathTransform = null,
|
||||||
|
Func<byte[], byte[]>? bodyTransform = null,
|
||||||
|
Action? onConnectionRefused = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_initialMessage = initialMessage;
|
_initialMessage = initialMessage;
|
||||||
_sendAsync = sendAsync;
|
_sendAsync = sendAsync;
|
||||||
_onComplete = onComplete;
|
_onComplete = onComplete;
|
||||||
|
_upstream = effectiveUpstream ?? options.Upstream;
|
||||||
|
_responseHandler = responseHandler;
|
||||||
|
_pathTransform = pathTransform;
|
||||||
|
_bodyTransform = bodyTransform;
|
||||||
|
_onConnectionRefused = onConnectionRefused;
|
||||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
if (!initialMessage.HasBody)
|
if (!initialMessage.HasBody)
|
||||||
@@ -61,12 +77,36 @@ internal sealed class UpstreamRequest
|
|||||||
{
|
{
|
||||||
if (body.Length > 0)
|
if (body.Length > 0)
|
||||||
{
|
{
|
||||||
_requestBody.Writer.TryWrite(body);
|
if (_bodyTransform is not null)
|
||||||
|
{
|
||||||
|
_bufferedBody.Add(body);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_requestBody.Writer.TryWrite(body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CompleteBody() =>
|
public void CompleteBody()
|
||||||
|
{
|
||||||
|
if (_bodyTransform is not null && _bufferedBody.Count > 0)
|
||||||
|
{
|
||||||
|
var totalLength = _bufferedBody.Sum(b => b.Length);
|
||||||
|
var concatenated = new byte[totalLength];
|
||||||
|
var offset = 0;
|
||||||
|
foreach (var chunk in _bufferedBody)
|
||||||
|
{
|
||||||
|
chunk.CopyTo(concatenated, offset);
|
||||||
|
offset += chunk.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transformed = _bodyTransform(concatenated);
|
||||||
|
_requestBody.Writer.TryWrite(transformed);
|
||||||
|
}
|
||||||
|
|
||||||
_requestBody.Writer.TryComplete();
|
_requestBody.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
|
||||||
public void Cancel()
|
public void Cancel()
|
||||||
{
|
{
|
||||||
@@ -84,22 +124,40 @@ internal sealed class UpstreamRequest
|
|||||||
HttpCompletionOption.ResponseHeadersRead,
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
_cancellationTokenSource.Token);
|
_cancellationTokenSource.Token);
|
||||||
|
|
||||||
await SendResponseHeadersAsync(response);
|
if (_responseHandler is not null)
|
||||||
await SendResponseBodyAsync(response);
|
{
|
||||||
|
await _responseHandler(response, _cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SendResponseHeadersAsync(response);
|
||||||
|
await SendResponseBodyAsync(response);
|
||||||
|
|
||||||
await _sendAsync(
|
await _sendAsync(
|
||||||
new TunnelMessage
|
new TunnelMessage
|
||||||
{
|
{
|
||||||
Type = TunnelMessageTypes.HttpResponseComplete,
|
Type = TunnelMessageTypes.HttpResponseComplete,
|
||||||
RequestId = _initialMessage.RequestId
|
RequestId = _initialMessage.RequestId
|
||||||
},
|
},
|
||||||
_cancellationTokenSource.Token);
|
_cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested)
|
catch (OperationCanceledException) when (_cancellationTokenSource.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
if (IsConnectionRefused(exception))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_onConnectionRefused?.Invoke();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await SendErrorAsync(exception);
|
await SendErrorAsync(exception);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -110,10 +168,39 @@ internal sealed class UpstreamRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string? ExtractModelName(TunnelMessage message)
|
||||||
|
{
|
||||||
|
if (message.Headers is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var header in message.Headers)
|
||||||
|
{
|
||||||
|
if (string.Equals(header.Name, ProtocolConstants.ModelHeader, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(header.Value))
|
||||||
|
{
|
||||||
|
return header.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private HttpRequestMessage BuildHttpRequest()
|
private HttpRequestMessage BuildHttpRequest()
|
||||||
{
|
{
|
||||||
|
var path = _initialMessage.PathAndQuery ?? "/";
|
||||||
|
if (_pathTransform is not null)
|
||||||
|
{
|
||||||
|
var transformed = _pathTransform(path);
|
||||||
|
if (transformed is not null)
|
||||||
|
{
|
||||||
|
path = transformed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var method = new HttpMethod(_initialMessage.Method ?? HttpMethod.Get.Method);
|
var method = new HttpMethod(_initialMessage.Method ?? HttpMethod.Get.Method);
|
||||||
var request = new HttpRequestMessage(method, BuildUpstreamUri(_options.Upstream, _initialMessage.PathAndQuery));
|
var request = new HttpRequestMessage(method, BuildUpstreamUri(_upstream, path));
|
||||||
|
|
||||||
if (_initialMessage.HasBody)
|
if (_initialMessage.HasBody)
|
||||||
{
|
{
|
||||||
@@ -185,7 +272,7 @@ internal sealed class UpstreamRequest
|
|||||||
private async Task SendResponseBodyAsync(HttpResponseMessage response)
|
private async Task SendResponseBodyAsync(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(_cancellationTokenSource.Token);
|
await using var stream = await response.Content.ReadAsStreamAsync(_cancellationTokenSource.Token);
|
||||||
var buffer = new byte[_options.ChunkSize];
|
var buffer = new byte[_upstream switch { _ => 64 * 1024 }];
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -224,6 +311,25 @@ internal sealed class UpstreamRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool IsConnectionRefused(Exception exception)
|
||||||
|
{
|
||||||
|
for (var current = exception; current is not null; current = current.InnerException)
|
||||||
|
{
|
||||||
|
if (current is SocketException socketException
|
||||||
|
&& socketException.SocketErrorCode == SocketError.ConnectionRefused)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Message.Contains("Connection refused", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<HeaderPair> CollectResponseHeaders(HttpResponseMessage response)
|
private static List<HeaderPair> CollectResponseHeaders(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
var headers = new List<HeaderPair>();
|
var headers = new List<HeaderPair>();
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ public static class ProtocolConstants
|
|||||||
public const string TokenHeader = "X-Ngino-Token";
|
public const string TokenHeader = "X-Ngino-Token";
|
||||||
public const string ClientIdHeader = "X-Ngino-Client-Id";
|
public const string ClientIdHeader = "X-Ngino-Client-Id";
|
||||||
public const string ReplacedCloseDescription = "ngino-replaced";
|
public const string ReplacedCloseDescription = "ngino-replaced";
|
||||||
|
public const string ModelHeader = "X-Ngino-Model";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1215,7 +1215,7 @@ internal sealed class ManagementStore
|
|||||||
using var connection = OpenConnection();
|
using var connection = OpenConnection();
|
||||||
using var command = connection.CreateCommand();
|
using var command = connection.CreateCommand();
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
SELECT g.name, gm.client_id, gm.client_pattern
|
SELECT g.id, gm.client_id, gm.client_pattern
|
||||||
FROM group_members gm
|
FROM group_members gm
|
||||||
INNER JOIN groups g ON gm.group_id = g.id
|
INNER JOIN groups g ON gm.group_id = g.id
|
||||||
""";
|
""";
|
||||||
@@ -1223,14 +1223,14 @@ internal sealed class ManagementStore
|
|||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
var groupName = reader.GetString(0);
|
var groupId = reader.GetString(0);
|
||||||
var explicitClientId = reader.IsDBNull(1) ? null : reader.GetString(1);
|
var explicitClientId = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||||
var pattern = reader.IsDBNull(2) ? null : reader.GetString(2);
|
var pattern = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(explicitClientId)
|
if (!string.IsNullOrWhiteSpace(explicitClientId)
|
||||||
&& result.TryGetValue(explicitClientId, out var explicitGroups))
|
&& result.TryGetValue(explicitClientId, out var explicitGroups))
|
||||||
{
|
{
|
||||||
explicitGroups.Add(groupName);
|
explicitGroups.Add(groupId);
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(pattern))
|
else if (!string.IsNullOrWhiteSpace(pattern))
|
||||||
{
|
{
|
||||||
@@ -1248,7 +1248,7 @@ internal sealed class ManagementStore
|
|||||||
{
|
{
|
||||||
if (regex.IsMatch(clientId) && result.TryGetValue(clientId, out var groups))
|
if (regex.IsMatch(clientId) && result.TryGetValue(clientId, out var groups))
|
||||||
{
|
{
|
||||||
groups.Add(groupName);
|
groups.Add(groupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,6 +269,11 @@ app.UseStaticFiles();
|
|||||||
|
|
||||||
app.MapAdminEndpoints(settings);
|
app.MapAdminEndpoints(settings);
|
||||||
|
|
||||||
|
app.MapGet("/favicon.ico", () =>
|
||||||
|
Results.File(
|
||||||
|
Path.Combine(app.Environment.WebRootPath, "favicon.ico"),
|
||||||
|
"image/x-icon"));
|
||||||
|
|
||||||
app.MapGet("/", () => Results.Redirect("/admin"));
|
app.MapGet("/", () => Results.Redirect("/admin"));
|
||||||
|
|
||||||
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
app.MapGet(settings.StatusPath, (HttpContext context, TunnelHub hub, ServerSettings serverSettings, EmbeddingCache embeddingCache, ManagementStore managementStore) =>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
{
|
{
|
||||||
private const string UnauthorizedMessage = "Missing or invalid Ngino token.";
|
private const string UnauthorizedMessage = "Missing or invalid Ngino token.";
|
||||||
|
|
||||||
|
private const string OllamaVersion = "0.32.5";
|
||||||
|
|
||||||
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"Connection",
|
"Connection",
|
||||||
@@ -72,6 +74,12 @@ internal static class ReverseProxyEndpoint
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsVersionRequest(context.Request, proxyPath))
|
||||||
|
{
|
||||||
|
await WriteVersionResponseAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
||||||
{
|
{
|
||||||
if (!groupAccess.IsClientAllowed(pathClientId))
|
if (!groupAccess.IsClientAllowed(pathClientId))
|
||||||
@@ -317,6 +325,15 @@ internal static class ReverseProxyEndpoint
|
|||||||
HttpMethods.IsGet(request.Method)
|
HttpMethods.IsGet(request.Method)
|
||||||
&& string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase);
|
&& string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsVersionRequest(HttpRequest request, PathString proxyPath) =>
|
||||||
|
HttpMethods.IsGet(request.Method)
|
||||||
|
&& string.Equals(proxyPath.Value, "/api/version", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static Task WriteVersionResponseAsync(HttpContext context) =>
|
||||||
|
context.Response.WriteAsJsonAsync(
|
||||||
|
new { version = OllamaVersion },
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
private static async Task HandleTagsAsync(
|
private static async Task HandleTagsAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
TunnelHub hub,
|
TunnelHub hub,
|
||||||
@@ -535,6 +552,12 @@ internal static class ReverseProxyEndpoint
|
|||||||
Headers = CollectRequestHeaders(context.Request, settings, managementStore)
|
Headers = CollectRequestHeaders(context.Request, settings, managementStore)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(requestedModel))
|
||||||
|
{
|
||||||
|
requestMessage.Headers ??= [];
|
||||||
|
requestMessage.Headers.Add(new HeaderPair(ProtocolConstants.ModelHeader, requestedModel));
|
||||||
|
}
|
||||||
|
|
||||||
await connection.SendAsync(requestMessage, context.RequestAborted);
|
await connection.SendAsync(requestMessage, context.RequestAborted);
|
||||||
requestBodyTask = ForwardRequestBodyAsync(context.Request, connection, requestId, hasBody, settings, logger);
|
requestBodyTask = ForwardRequestBodyAsync(context.Request, connection, requestId, hasBody, settings, logger);
|
||||||
_ = requestBodyTask.ContinueWith(
|
_ = requestBodyTask.ContinueWith(
|
||||||
|
|||||||
|
After Width: | Height: | Size: 56 KiB |
@@ -58,16 +58,12 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border: 1px solid #4c5d74;
|
border: 1px solid #4c5d74;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #223044;
|
background: #223044;
|
||||||
color: #c8f2df;
|
object-fit: cover;
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand strong,
|
.brand strong,
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ function renderClients() {
|
|||||||
|
|
||||||
function clientsTable(clients) {
|
function clientsTable(clients) {
|
||||||
const clientGroups = state.summary?.clientGroups || {};
|
const clientGroups = state.summary?.clientGroups || {};
|
||||||
|
const groupNames = Object.fromEntries((state.summary?.groups || []).map((g) => [g.id, g.name]));
|
||||||
const rows = clients.map((client) => {
|
const rows = clients.map((client) => {
|
||||||
const groups = clientGroups[client.id] || [];
|
const groups = clientGroups[client.id] || [];
|
||||||
return `
|
return `
|
||||||
@@ -516,7 +517,7 @@ function clientsTable(clients) {
|
|||||||
<td>${modelBadges(client.activeModels)}</td>
|
<td>${modelBadges(client.activeModels)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="badge-row">
|
<div class="badge-row">
|
||||||
${groups.length ? groups.map((g) => `<a class="badge" href="#groups/${encodeURIComponent(g)}">${escapeHtml(g)}</a>`).join("") : `<span class="cell-sub">None</span>`}
|
${groups.length ? groups.map((g) => `<a class="badge" href="#groups/${encodeURIComponent(g)}">${escapeHtml(groupNames[g] || g)}</a>`).join("") : `<span class="cell-sub">None</span>`}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark">RL</span>
|
<img class="brand-mark" src="/admin/Ngino_logo_symbol.png" alt="Ngino">
|
||||||
<span>
|
<span>
|
||||||
<strong>Ngino</strong>
|
<strong>Ngino</strong>
|
||||||
<small>Admin</small>
|
<small>Admin</small>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
using Ngino.Client;
|
using Ngino.Client;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -32,4 +33,29 @@ public sealed class UpstreamRequestTests
|
|||||||
|
|
||||||
Assert.Contains("origin-form path", exception.Message);
|
Assert.Contains("origin-form path", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsConnectionRefused_DetectsSocketConnectionRefused()
|
||||||
|
{
|
||||||
|
var socketException = new SocketException((int)SocketError.ConnectionRefused);
|
||||||
|
var exception = new HttpRequestException("Connection refused", socketException);
|
||||||
|
|
||||||
|
Assert.True(UpstreamRequest.IsConnectionRefused(exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsConnectionRefused_DetectsConnectionRefusedByMessage()
|
||||||
|
{
|
||||||
|
var exception = new HttpRequestException("Connection refused (localhost:8081)");
|
||||||
|
|
||||||
|
Assert.True(UpstreamRequest.IsConnectionRefused(exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsConnectionRefused_IgnoresUnrelatedFailures()
|
||||||
|
{
|
||||||
|
var exception = new HttpRequestException("Connection reset by peer");
|
||||||
|
|
||||||
|
Assert.False(UpstreamRequest.IsConnectionRefused(exception));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||