Files
Daniel Hiltgen a6293eb516 llm: allow iGPU mmproj offload with fit padding (#16996)
* llm: allow iGPU mmproj offload with fit padding

llama.cpp's fit pass sizes text-model placement before the multimodal projector is loaded. Ollama had been avoiding that risk on non-Metal iGPUs by disabling projector offload entirely, which forces CLIP onto CPU on GB10 and Strix Halo even when the projector has ample memory available.

Let integrated GPUs use the same projector-memory check as other GPUs. When projector offload is enabled, add the estimated projector memory plus the existing 1 GiB headroom to Ollama-owned LLAMA_ARG_FIT_TARGET so fit leaves space for the later projector allocation. If Ollama/device setup already supplied a fit target, add the projector pad to it. If the user set LLAMA_ARG_FIT_TARGET explicitly, leave it exactly as provided.

Fixes #16419

* review comments
2026-07-07 15:28:42 -07:00

2796 lines
82 KiB
Go

// llama_server.go wraps the llama-server binary as a subprocess
//
// Ollama uses two chat paths with llama-server. Models with explicit Ollama
// renderers/parsers, Harmony handling, MLX, or an enabled Go TEMPLATE layer
// still render prompts in Go and call /completion. Other GGUF chat models use
// llama-server's chat_template handling through /v1/chat/completions.
//
// For structured output, JSON schemas are passed directly to llama-server via
// its json_schema field (avoiding the CGO SchemaToGrammar dependency). Raw BNF
// grammars are passed via the grammar field.
//
// llama-server auto-detects GPU layers (-ngl), thread count (-t), and flash
// attention (--flash-attn).
package llm
import (
"bufio"
"bytes"
"context"
crand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/semaphore"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/fs/ggml"
"github.com/ollama/ollama/ml"
)
var grammarJSON = `
root ::= object
value ::= object | array | string | number | ("true" | "false" | "null") ws
object ::=
"{" ws (
string ":" ws value
("," ws string ":" ws value)*
)? ws "}"
array ::=
"[" ws (
value
("," ws value)*
)? ws "]"
string ::=
"\"" (
[^"\\\x7F\x00-\x1F] |
"\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]) # escapes
)* "\""
number ::= ("-"? ([0-9] | [1-9] [0-9]*)) ("." [0-9]+)? ([eE] [-+]? [0-9]+)?
# Optional space: by convention, applied in this grammar after literal chars when allowed
ws ::= ([ \t\n] ws)?
`
// DefaultEmbeddingNumBatch is the default NumBatch used for embedding models
// when neither the model nor the request specifies num_batch.
const (
DefaultEmbeddingNumBatch = 2048
openEndedGenerationContextMultiplier = 10
)
const (
llamaArgFitTargetEnv = "LLAMA_ARG_FIT_TARGET"
bytesPerMiB = 1 << 20
// mmprojOffloadHeadroom leaves 1 GiB for backend buffers beyond projector weights.
mmprojOffloadHeadroom = 1 << 30
)
// DefaultEmbeddingNumBatchForContext caps the embedding batch default to the
// active context length before it is passed to llama-server.
func DefaultEmbeddingNumBatchForContext(numCtx int) int {
if numCtx > 0 {
return min(DefaultEmbeddingNumBatch, numCtx)
}
return DefaultEmbeddingNumBatch
}
// WithDefaultEmbeddingNumBatch applies the llama-server embedding batch
// default to a copy of opts.
func WithDefaultEmbeddingNumBatch(opts api.Options) api.Options {
opts.NumBatch = DefaultEmbeddingNumBatchForContext(opts.NumCtx)
return opts
}
func boundedNumPredict(numPredict, numCtx int) int {
if numCtx <= 0 {
return numPredict
}
// Ollama's default num_predict=-1 means "generate until a stop condition".
// llama-server still needs a finite request budget, so keep open-ended
// generations bounded while allowing several full context windows.
limit := openEndedGenerationContextMultiplier * numCtx
if numPredict < 0 || numPredict > limit {
return limit
}
return numPredict
}
// llamaServerRunner wraps an upstream llama-server process and implements the LlamaServer interface.
// It communicates with llama-server over HTTP.
type llamaServerRunner struct {
port int
cmd *exec.Cmd
done chan struct{}
doneErr error
client *http.Client
memoryMu sync.RWMutex
memTotal uint64 // actual total buffer size parsed from llama-server logs (bytes)
memGPU uint64 // actual GPU buffer size parsed from llama-server logs (bytes)
memModelFileBacked uint64 // model weight bytes whose buffers mirror the on-disk file (mmap views + direct device copies); excludes repacked copies like CPU_REPACK
memCPUMappedModel uint64 // model weight bytes in mmap-backed CPU buffers (e.g. CPU_Mapped), parsed from llama-server logs
gpuLayers uint64 // model layers loaded on GPU, parsed from llama-server logs
gpuLayerOverflow int // number of GPU-selected layers partially overflowed to CPU
status *StatusWriter
options api.Options
modelPath string
// mediaMarker must match the LLAMA_MEDIA_MARKER value passed to llama-server.
// llama.cpp randomizes this by default; Ollama renders stable [img-N] markers
// and rewrites them before forwarding the request.
mediaMarker string
// Per-device VRAM tracking, populated from llama-server log parsing.
// Keys are device names from llama-server output (e.g., "CUDA0", "ROCm0", "MTL0").
vramByDevice map[string]uint64
// System-reported free VRAM per device at model load time, parsed from
// "using device CUDA0 ... - 15221 MiB free" log lines. This reflects
// real system state including external VRAM consumers (on platforms where
// the GPU driver reports accurately). Keys match vramByDevice (e.g., "CUDA0").
systemFreeAtLoad map[string]uint64
// gpus is the list of GPU devices assigned to this runner at creation time,
// used to map DeviceIDs to device names for VRAMByGPU lookups.
gpus []ml.DeviceInfo
ggml *ggml.GGML
totalLayers uint64 // maximum offloadable model layers
loadStart time.Time
loadActivity atomic.Int64
loadTracking atomic.Bool
rawEmbeddings bool
sem *semaphore.Weighted
launch llamaServerLaunchConfig
output *memoryParsingWriter
mmprojOffloadOOMRetried bool
}
type llamaServerLaunchConfig struct {
modelPath string
modelArch string
projectors []string
mmprojMemory uint64
modelLayers uint64
adapters []string
opts api.Options
numParallel int
kvCacheType string
embedding bool
config LlamaServerConfig
gpus []ml.DeviceInfo
gpuLibs []string
extraEnvs map[string]string
forceNoMMProjOffload bool
}
func newLlamaServerHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
DisableKeepAlives: true,
Proxy: nil,
},
}
}
var defaultLlamaServerHTTPClient = newLlamaServerHTTPClient()
func (s *llamaServerRunner) httpClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultLlamaServerHTTPClient
}
func (s *llamaServerRunner) ModelPath() string {
return s.modelPath
}
func (s *llamaServerRunner) Pid() int {
if s.cmd != nil && s.cmd.Process != nil {
return s.cmd.Process.Pid
}
return 0
}
func (s *llamaServerRunner) GetPort() int {
return s.port
}
func (s *llamaServerRunner) HasExited() bool {
return s.cmd != nil && s.cmd.ProcessState != nil && s.cmd.ProcessState.ExitCode() >= 0
}
func (s *llamaServerRunner) llamaServerMediaMarker() string {
if s.mediaMarker != "" {
return s.mediaMarker
}
return "<__media__>"
}
func newLlamaServerMediaMarker() string {
var b [16]byte
if _, err := crand.Read(b[:]); err == nil {
return fmt.Sprintf("<__ollama_media_%x__>", b)
}
return fmt.Sprintf("<__ollama_media_%d_%d__>", time.Now().UnixNano(), rand.Int63())
}
func (s *llamaServerRunner) completionPrompt(prompt, leadingBOS string) string {
if s.tokenizerAddsBOS() {
if leadingBOS != "" && strings.HasPrefix(prompt, leadingBOS) {
return strings.TrimPrefix(prompt, leadingBOS)
}
if strings.HasPrefix(prompt, "<bos>") {
return strings.TrimPrefix(prompt, "<bos>")
}
}
return prompt
}
func (s *llamaServerRunner) tokenizerAddsBOS() bool {
if s.ggml == nil {
return false
}
kv := s.ggml.KV()
if kv.String("tokenizer.ggml.pre") == "lfm2" {
return true
}
// llama.cpp forces add_bos on for Gemma4 at load time, even for GGUFs
// whose tokenizer.ggml.add_bos_token metadata is explicitly false. Some
// GGUFs omit tokenizer.ggml.pre and are still treated as Gemma4 from
// tokenizer.ggml.model.
if kv.String("tokenizer.ggml.pre") == "gemma4" || kv.String("tokenizer.ggml.model") == "gemma4" {
return true
}
return kv.Bool("tokenizer.ggml.add_bos_token")
}
func (s *llamaServerRunner) completionPromptForRequest(ctx context.Context, req CompletionRequest) (any, error) {
prompt := s.completionPrompt(req.Prompt, req.LeadingBOS)
if !req.Truncate || len(req.Media) > 0 || s.options.NumCtx <= 1 || len(prompt) < s.options.NumCtx {
return prompt, nil
}
tokens, err := s.tokenize(ctx, prompt, true, nil)
if err != nil {
return nil, err
}
fullPromptLimit := s.options.NumCtx - 1
if len(tokens) <= fullPromptLimit {
return prompt, nil
}
if !s.launch.config.ContextShift {
return nil, api.StatusError{
StatusCode: http.StatusBadRequest,
ErrorMessage: "the prompt is longer than the context length currently available to the model; shorten the prompt, adjust the context length in settings, or use a model with a longer context length",
}
}
nKeep := req.Options.NumKeep
if nKeep < 0 {
nKeep = len(tokens)
}
if s.tokenizerAddsBOS() {
nKeep++
}
nKeep = min(nKeep, fullPromptLimit)
limit := contextShiftPromptLimit(s.options.NumCtx, nKeep)
discard := len(tokens) - limit
truncated := make([]int, 0, limit)
truncated = append(truncated, tokens[:nKeep]...)
truncated = append(truncated, tokens[nKeep+discard:]...)
slog.Warn("truncating input prompt", "limit", limit, "prompt", len(tokens), "keep", nKeep, "new", len(truncated))
return truncated, nil
}
func contextShiftPromptLimit(numCtx, numKeep int) int {
if numCtx <= 1 {
return 0
}
numKeep = max(0, min(numKeep, numCtx-1))
// Match the old runners' first context shift: preserve num_keep, then free
// roughly half of the remaining context before generation needs the slot.
return numCtx - max((numCtx-numKeep)/2, 1)
}
func (s *llamaServerRunner) ContextLength() int {
return s.options.NumCtx
}
// FindLlamaServer locates the llama-server binary in lib/ollama/.
// There is a single binary that dynamically loads GPU backends at runtime.
func FindLlamaServer() (string, error) {
path, candidates, err := findLlamaCppBinary("llama-server", defaultLlamaCppBinarySearch())
if err != nil {
return "", fmt.Errorf("llama-server binary not found (checked: %s). Run 'cmake -S llama/server --preset cpu && cmake --build --preset cpu' first", strings.Join(candidates, ", "))
}
return path, nil
}
// startLlamaServer spawns the upstream llama-server process with appropriate CLI flags.
func startLlamaServer(launch llamaServerLaunchConfig, out io.Writer) (cmd *exec.Cmd, port int, err error) {
exe, err := FindLlamaServer()
if err != nil {
return nil, 0, err
}
// Allocate a port
port = 0
if a, err := net.ResolveTCPAddr("tcp", "localhost:0"); err == nil {
var l *net.TCPListener
if l, err = net.ListenTCP("tcp", a); err == nil {
port = l.Addr().(*net.TCPAddr).Port
l.Close()
}
}
if port == 0 {
slog.Debug("ResolveTCPAddr failed, using random port")
port = rand.Intn(65535-49152) + 49152
}
// Build CLI flags — minimal set, let llama-server auto-detect the rest
params := []string{
"--model", launch.modelPath,
"--port", strconv.Itoa(port),
"--host", "127.0.0.1",
"--no-webui",
"--offline",
"-c", strconv.Itoa(launch.opts.NumCtx * launch.numParallel),
"-np", strconv.Itoa(launch.numParallel),
}
params = appendLlamaServerLogArgs(params)
params = appendJinjaArgs(params, launch.config)
params = appendMMProjArgs(params, launch)
params = appendMTPDraftArgs(params, launch.config, launch.opts)
params = append(params, qwenVLServerArgs(launch.modelArch)...)
// LoRA adapters
for _, adapter := range launch.adapters {
params = append(params, "--lora", adapter)
}
// UseMmap
if launch.opts.UseMMap != nil && !*launch.opts.UseMMap {
params = append(params, "--no-mmap")
}
// KV cache type
if launch.kvCacheType != "" {
params = append(params, "--cache-type-k", launch.kvCacheType, "--cache-type-v", launch.kvCacheType)
}
params = appendFlashAttentionArgs(params, launch.gpus)
params = appendBatchArgs(params, launch.opts, launch.embedding, launch.numParallel)
// GPU layer offloading — only pass if user explicitly set it (non-default).
// Default behavior: let llama-server auto-detect via -ngl auto.
if launch.opts.NumGPU > 0 {
params = append(params, "-ngl", strconv.Itoa(launch.opts.NumGPU))
} else if launch.opts.NumGPU == 0 {
// Explicit 0 means CPU only
params = append(params, "-ngl", "0")
}
// NumGPU == -1 (default): don't pass -ngl, let llama-server auto-detect
// Thread count — only pass if user explicitly set it.
// Default behavior: let llama-server auto-detect.
if launch.opts.NumThread > 0 {
params = append(params, "-t", strconv.Itoa(launch.opts.NumThread))
}
params = appendMainGPUArgs(params, launch.opts)
params = appendContextShiftArgs(params, launch.opts, launch.config.ContextShift)
// Set up library paths for GPU backend discovery
cmd = exec.Command(exe, params...)
if out != nil {
// os/exec serializes Write calls when stdout and stderr share a writer.
cmd.Stdout = out
cmd.Stderr = out
}
cmd.SysProcAttr = LlamaServerSysProcAttr
SetupLlamaServerCommandEnv(cmd, exe, launch.gpuLibs, launch.extraEnvsForStart())
slog.Info("starting llama-server", "cmd", cmd)
slog.Debug("subprocess", "", filteredEnv(cmd.Env))
if err = cmd.Start(); err != nil {
return nil, 0, err
}
return cmd, port, nil
}
// SetupLlamaServerCommandEnv configures the environment for a llama-server
// subprocess so discovery and real model runners use the same library search
// paths and GPU backend selection.
func SetupLlamaServerCommandEnv(cmd *exec.Cmd, exe string, gpuLibs []string, extraEnvs map[string]string) {
cmd.Env = os.Environ()
envUpdates := make(map[string]string, len(extraEnvs)+2)
for k, v := range extraEnvs {
envUpdates[k] = v
}
libraryPaths := llamaServerLibraryPaths(exe, gpuLibs, envUpdates)
pathEnv := llamaServerLibraryPathEnv()
envUpdates[pathEnv] = strings.Join(libraryPaths, string(filepath.ListSeparator))
applied := make(map[string]bool, len(envUpdates))
for i := range cmd.Env {
key, _, ok := strings.Cut(cmd.Env[i], "=")
if !ok {
continue
}
for updateKey, updateVal := range envUpdates {
if strings.EqualFold(key, updateKey) {
cmd.Env[i] = updateKey + "=" + updateVal
applied[updateKey] = true
}
}
}
for key, val := range envUpdates {
if !applied[key] {
cmd.Env = append(cmd.Env, key+"="+val)
}
}
}
func llamaServerLibraryPathEnv() string {
switch runtime.GOOS {
case "windows":
return "PATH"
case "darwin":
return "DYLD_LIBRARY_PATH"
default:
return "LD_LIBRARY_PATH"
}
}
func llamaServerLibraryPaths(exe string, gpuLibs []string, envUpdates map[string]string) []string {
llamaDir := filepath.Dir(exe)
seen := map[string]bool{}
var libraryPaths []string
addPath := func(path string) {
if path == "" || seen[path] {
return
}
seen[path] = true
libraryPaths = append(libraryPaths, path)
}
// Library path ordering:
// 1. llama-server's own directory — ggml-base, ggml-cpu, libllama
// 2. GPU variant directories — cublas, cudart, backend DLL/.so
// 3. User/system library path
addPath(llamaDir)
for _, dir := range gpuLibs {
if dir == ml.LibOllamaPath || dir == llamaDir {
continue
}
if envUpdates["GGML_BACKEND_PATH"] == "" {
if backend := findLlamaServerGPUBackend(dir); backend != "" {
envUpdates["GGML_BACKEND_PATH"] = backend
}
}
addPath(dir)
}
if libraryPath, ok := os.LookupEnv(llamaServerLibraryPathEnv()); ok {
for _, dir := range filepath.SplitList(libraryPath) {
addPath(dir)
}
}
return adjustPlatformLibraryPaths(libraryPaths, gpuLibs)
}
func findLlamaServerGPUBackend(dir string) string {
patterns := []string{
"libggml-*.so*",
"libggml-*.dylib",
"libggml-*.dll",
"ggml-*.dll",
}
var candidates []string
for _, pattern := range patterns {
matches, _ := filepath.Glob(filepath.Join(dir, pattern))
candidates = append(candidates, matches...)
}
slices.Sort(candidates)
for _, match := range candidates {
if isLlamaServerGPUBackend(match) {
return match
}
}
return ""
}
func isLlamaServerGPUBackend(path string) bool {
name := strings.ToLower(filepath.Base(path))
for _, prefix := range []string{
"libggml-base",
"ggml-base",
"libggml-cpu",
"ggml-cpu",
} {
if strings.HasPrefix(name, prefix) {
return false
}
}
return true
}
func embeddingBatchSize(opts api.Options, numParallel int) int {
batchSize := opts.NumBatch
if batchSize <= 0 {
return 0
}
if opts.NumCtx > 0 {
batchSize = min(batchSize, opts.NumCtx*max(numParallel, 1))
}
return batchSize
}
func appendLlamaServerLogArgs(params []string) []string {
// Keep startup memory/offload lines visible for scheduler accounting.
return append(params,
"--log-verbosity", "4",
"--no-log-prefix",
"--no-log-timestamps",
)
}
func appendBatchArgs(params []string, opts api.Options, embedding bool, numParallel int) []string {
if embedding {
params = append(params, "--embedding")
if batchSize := embeddingBatchSize(opts, numParallel); batchSize > 0 {
params = append(params, "-b", strconv.Itoa(batchSize), "-ub", strconv.Itoa(batchSize))
}
return params
}
if opts.NumBatch > 0 {
params = append(params, "-b", strconv.Itoa(opts.NumBatch), "-ub", strconv.Itoa(opts.NumBatch))
}
return params
}
// LlamaServerFlashAttention resolves the flash-attention mode passed to llama-server.
func LlamaServerFlashAttention(gpus []ml.DeviceInfo) ml.FlashAttentionType {
enabled := envconfig.FlashAttention(false)
userSet := enabled == envconfig.FlashAttention(true)
if userSet {
if enabled {
return ml.FlashAttentionEnabled
}
return ml.FlashAttentionDisabled
}
if !ml.FlashAttentionSupported(gpus) {
return ml.FlashAttentionDisabled
}
return ml.FlashAttentionAuto
}
func appendFlashAttentionArgs(params []string, gpus []ml.DeviceInfo) []string {
switch LlamaServerFlashAttention(gpus) {
case ml.FlashAttentionEnabled:
return append(params, "--flash-attn", "on")
case ml.FlashAttentionDisabled:
return append(params, "--flash-attn", "off")
default:
return append(params, "--flash-attn", "auto")
}
}
func appendMainGPUArgs(params []string, opts api.Options) []string {
if opts.MainGPU == nil {
return params
}
return append(params, "--split-mode", "none", "--main-gpu", strconv.Itoa(*opts.MainGPU))
}
func appendMMProjArgs(params []string, launch llamaServerLaunchConfig) []string {
if len(launch.projectors) == 0 {
return params
}
params = append(params, "--mmproj", launch.projectors[0])
if disable, reason := launch.mmprojOffloadDisabled(); disable {
slog.Info("disabling multimodal projector offload", "reason", reason, "model", launch.modelPath, "projector", launch.projectors[0])
params = append(params, "--no-mmproj-offload")
}
return params
}
func (launch llamaServerLaunchConfig) mmprojOffloadDisabled() (bool, string) {
if launch.forceNoMMProjOffload {
return true, "startup-oom-retry"
}
return shouldDisableMMProjOffload(launch.opts, launch.gpus, launch.modelLayers, launch.mmprojMemory)
}
func shouldDisableMMProjOffload(opts api.Options, gpus []ml.DeviceInfo, modelLayers, mmprojMemory uint64) (bool, string) {
if opts.NumGPU == 0 {
return true, "cpu-only"
}
if opts.NumGPU > 0 && modelLayers > 0 && uint64(opts.NumGPU) < modelLayers {
return true, "partial-text-offload"
}
requiredMemory := mmprojMemory + mmprojOffloadHeadroom
for _, gpu := range gpus {
memory := gpu.FreeMemory
if memory == 0 || (gpu.TotalMemory > 0 && gpu.TotalMemory < memory) {
memory = gpu.TotalMemory
}
if memory > 0 && memory < requiredMemory {
return true, "limited-vram"
}
}
return false, ""
}
func (launch llamaServerLaunchConfig) extraEnvsForStart() map[string]string {
pad, ok := launch.mmprojFitTargetMiB()
if !ok {
return launch.extraEnvs
}
if existing, ok := launch.extraEnvs[llamaArgFitTargetEnv]; ok {
existingTarget, err := strconv.ParseUint(existing, 10, 64)
if err != nil {
slog.Warn("invalid llama-server fit target", "env", llamaArgFitTargetEnv, "value", existing, "error", err)
return launch.extraEnvs
}
envs := cloneStringMap(launch.extraEnvs)
envs[llamaArgFitTargetEnv] = strconv.FormatUint(existingTarget+pad, 10)
return envs
}
if _, ok := os.LookupEnv(llamaArgFitTargetEnv); ok {
// Preserve an inherited user override. SetupLlamaServerCommandEnv
// will pass it through unless extraEnvs overrides it.
return launch.extraEnvs
}
envs := cloneStringMap(launch.extraEnvs)
envs[llamaArgFitTargetEnv] = strconv.FormatUint(pad, 10)
return envs
}
func (launch llamaServerLaunchConfig) mmprojFitTargetMiB() (uint64, bool) {
if len(launch.projectors) == 0 || launch.mmprojMemory == 0 {
return 0, false
}
if disable, _ := launch.mmprojOffloadDisabled(); disable {
return 0, false
}
requiredMemory := launch.mmprojMemory + mmprojOffloadHeadroom
return (requiredMemory + bytesPerMiB - 1) / bytesPerMiB, true
}
// mmprojMemoryRequirement is a stopgap until fit accounts for mmproj memory directly.
func mmprojMemoryRequirement(modelPath string, f *ggml.GGML, projectors []string) (uint64, error) {
if len(projectors) == 0 {
return 0, nil
}
if projectors[0] == modelPath {
if f == nil {
return 0, errors.New("read inline mmproj metadata: missing model metadata")
}
var size uint64
for _, prefix := range []string{"v.", "mm.", "a."} {
for _, tensor := range f.Tensors().Items(prefix) {
size += tensor.Size()
}
}
if size == 0 {
return 0, errors.New("read inline mmproj metadata: no projector tensors found")
}
return size, nil
}
file, err := os.Open(projectors[0])
if err != nil {
return 0, fmt.Errorf("read mmproj metadata %q: %w", projectors[0], err)
}
defer file.Close()
projector, err := ggml.Decode(file, 1024)
if err != nil {
return 0, fmt.Errorf("read mmproj metadata %q: %w", projectors[0], err)
}
var size uint64
for _, tensor := range projector.Tensors().Items() {
size += tensor.Size()
}
if size == 0 {
return 0, fmt.Errorf("read mmproj metadata %q: no projector tensors found", projectors[0])
}
return size, nil
}
func appendJinjaArgs(params []string, config LlamaServerConfig) []string {
if config.DisableJinja {
// Go-rendered chat paths send already-rendered prompts through completion
// endpoints. Override any GGUF chat template so llama-server startup
// does not parse an unused model template. llama-server still requires a
// template name, so chatml is a startup-only placeholder and must not be
// used for request routing.
return append(params, "--no-jinja", "--chat-template", "chatml")
}
return params
}
func appendContextShiftArgs(params []string, opts api.Options, enabled bool) []string {
if !enabled {
return params
}
params = append(params, "--context-shift")
if opts.NumKeep > 0 {
params = append(params, "--keep", strconv.Itoa(opts.NumKeep))
}
return params
}
func appendMTPDraftArgs(params []string, config LlamaServerConfig, opts api.Options) []string {
if !config.EnableMTP && config.DraftModelPath == "" {
return params
}
if opts.DraftNumPredict <= 0 {
return params
}
params = append(params, "--spec-type", "draft-mtp")
params = append(params, "--spec-draft-n-max", strconv.Itoa(opts.DraftNumPredict))
params = append(params, "--spec-draft-backend-sampling")
if config.DraftModelPath != "" {
params = append(params, "--spec-draft-model", config.DraftModelPath)
}
return params
}
func hasMTPDraft(f *ggml.GGML) bool {
if f.KV().Uint("nextn_predict_layers") > 0 {
return true
}
return hasLegacyQwenMTPDraft(f.KV().Architecture(), f.Tensors().Items("mtp."))
}
func hasLegacyQwenMTPDraft(arch string, tensors []*ggml.Tensor) bool {
switch arch {
case "qwen35", "qwen35moe":
return len(tensors) > 0
default:
return false
}
}
// NewLlamaServerRunner creates a new llama-server runner that wraps the upstream llama-server binary.
func NewLlamaServerRunner(
gpus []ml.DeviceInfo,
modelPath string,
f *ggml.GGML,
adapters, projectors []string,
opts api.Options,
numParallel int,
kvCacheType string,
config LlamaServerConfig,
) (LlamaServer, error) {
// Check if this is an embedding model
arch := f.KV().Architecture()
_, isEmbedding := f.KV()[fmt.Sprintf("%s.pooling_type", arch)]
// Older Ollama-format GGUFs store vision tensors (v.*, mm.*) inline in
// the main model file rather than in a separate projector layer. When
// the arch has a llama/compat clip handler, we can point --mmproj at
// the same file and the in-process shim translates the two views.
//
// If we auto-enable --mmproj for an arch whose clip handler doesn't
// exist yet, upstream's clip loader sees un-translated Ollama tensors
// and aborts model load. So gate on an explicit allowlist that mirrors
// the compat layer's clip-side coverage in llama/compat/.
compatClipArches := map[string]bool{
"gemma3": true,
"gemma4": true,
"qwen35": true,
"qwen35moe": true,
"qwen25vl": true,
"qwen3vl": true,
"qwen3vlmoe": true,
"mistral3": true,
"deepseekocr": true,
"glmocr": true,
"llama4": true,
"nemotron_h_omni": true,
// Add entries as llama/compat grows clip handlers.
}
if len(projectors) == 0 &&
len(f.Tensors().Items("v.")) > 0 &&
compatClipArches[arch] {
projectors = []string{modelPath}
}
mmprojMemory, err := mmprojMemoryRequirement(modelPath, f, projectors)
if err != nil {
return nil, err
}
if config.DraftModelPath == "" && hasMTPDraft(f) {
config.EnableMTP = true
}
gpuLibs := ml.LibraryPaths(gpus)
status := NewStatusWriter(os.Stderr)
// memWriter wraps the status writer and parses buffer size lines from llama-server logs
memWriter := &memoryParsingWriter{inner: status}
mediaMarker := newLlamaServerMediaMarker()
extraEnvs := ml.GetDevicesEnv(gpus)
serverEnvs := make(map[string]string, len(extraEnvs)+1)
for k, v := range extraEnvs {
serverEnvs[k] = v
}
serverEnvs["LLAMA_MEDIA_MARKER"] = mediaMarker
launch := llamaServerLaunchConfig{
modelPath: modelPath,
modelArch: arch,
projectors: slices.Clone(projectors),
mmprojMemory: mmprojMemory,
modelLayers: f.KV().BlockCount() + 1,
adapters: slices.Clone(adapters),
opts: opts,
numParallel: numParallel,
kvCacheType: kvCacheType,
embedding: isEmbedding,
config: config,
gpus: slices.Clone(gpus),
gpuLibs: slices.Clone(gpuLibs),
extraEnvs: cloneStringMap(serverEnvs),
}
s := &llamaServerRunner{
client: newLlamaServerHTTPClient(),
status: status,
options: opts,
modelPath: modelPath,
mediaMarker: mediaMarker,
vramByDevice: make(map[string]uint64),
systemFreeAtLoad: make(map[string]uint64),
gpus: gpus,
ggml: f,
totalLayers: f.KV().BlockCount() + 1,
rawEmbeddings: legacyEmbeddingsWereRaw(f.KV()),
sem: semaphore.NewWeighted(int64(numParallel)),
launch: launch,
output: memWriter,
}
// Point the memory parsing writer at this runner so values are updated as logs stream in
memWriter.runner = s
if err := s.startProcess(); err != nil {
msg := s.lastErrMsg()
return nil, fmt.Errorf("error starting llama-server: %v %s", err, msg)
}
return s, nil
}
func cloneStringMap(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func legacyEmbeddingsWereRaw(kv ggml.KV) bool {
arch := kv.Architecture()
if _, ok := kv[fmt.Sprintf("%s.pooling_type", arch)]; !ok {
return false
}
// Legacy /api/embeddings returned runner output, so preserve only old raw embed paths.
switch arch {
case "bert":
if kv.String("tokenizer.ggml.model", "bert") != "bert" {
return true
}
return !kv.Bool("normalize_embeddings", true)
case "nomic-bert", "nomic-bert-moe":
return !kv.Bool("normalize_embeddings", false)
case "gemma3", "gemma-embedding", "qwen3":
return false
default:
return false
}
}
func (s *llamaServerRunner) startProcess() error {
cmd, port, err := startLlamaServer(s.launch, s.output)
if err != nil {
return err
}
s.cmd = cmd
s.port = port
s.done = make(chan struct{})
s.doneErr = nil
s.loadStart = time.Now()
s.startLoadTracking(s.loadStart)
// Reap subprocess when it exits.
go func(cmd *exec.Cmd, done chan struct{}) {
err := cmd.Wait()
s.doneErr = err
if msg := s.lastErrMsg(); err != nil && msg != "" {
slog.Error("llama-server terminated", "error", err, "exit", ExitStatusFromError(err))
s.doneErr = errors.New(msg)
}
close(done)
}(s.cmd, s.done)
return nil
}
func qwenVLServerArgs(modelArch string) []string {
switch modelArch {
case "qwen2vl", "qwen25vl", "qwen3vl", "qwen3vlmoe":
// Upstream mtmd warns that Qwen-VL needs at least 1024 image tokens for
// correct grounding/counting behavior; the GGUF metadata default is too low.
return []string{"--image-min-tokens", "1024"}
default:
return nil
}
}
// Load waits for llama-server to finish loading the model. llama-server loads
// the model at startup and auto-detects GPU layers, so this just waits for
// health to report ready. The scheduler handles full-fit preflight for
// llama-server before this point.
func (s *llamaServerRunner) Load(ctx context.Context, systemInfo ml.SystemInfo, gpus []ml.DeviceInfo, _ bool) ([]ml.DeviceID, error) {
slog.Info("loading model via llama-server", "model", s.modelPath)
if err := s.WaitUntilRunning(ctx); err != nil {
retried, retryErr := s.retryWithMMProjCPUOffload(err)
if retryErr != nil {
return nil, retryErr
}
if !retried {
return nil, err
}
if err := s.WaitUntilRunning(ctx); err != nil {
return nil, fmt.Errorf("llama-server startup failed after projector CPU offload retry: %w", err)
}
}
// Verify that buffer size parsing captured GPU allocations.
// If parsing failed (e.g., llama-server log format changed), warn so the
// issue is visible in logs when users report problems.
if len(s.gpus) > 0 && !s.hasParsedVRAM() {
slog.Warn("llama-server VRAM tracking: no per-device buffer sizes were parsed from "+
"llama-server logs. VRAM accounting will be inaccurate. This may indicate a "+
"change in llama-server's log format — check for 'buffer size' lines in the output.",
"model", s.modelPath, "gpus", len(s.gpus))
}
if s.options.MainGPU != nil && *s.options.MainGPU >= 0 && *s.options.MainGPU < len(gpus) {
return []ml.DeviceID{gpus[*s.options.MainGPU].DeviceID}, nil
}
// Return device IDs for all GPUs when llama-server manages layer placement itself.
deviceIDs := make([]ml.DeviceID, len(gpus))
for i, g := range gpus {
deviceIDs[i] = g.DeviceID
}
return deviceIDs, nil
}
func (s *llamaServerRunner) retryWithMMProjCPUOffload(loadErr error) (bool, error) {
if !s.shouldRetryMMProjCPUOffload(loadErr) {
return false, nil
}
slog.Warn("llama-server startup failed with projector GPU offload; retrying with projector CPU offload", "model", s.modelPath, "error", loadErr)
s.mmprojOffloadOOMRetried = true
s.launch.forceNoMMProjOffload = true
if err := s.stopProcess(); err != nil {
return false, fmt.Errorf("llama-server startup failed before projector CPU offload retry: %w; error stopping failed process: %v", loadErr, err)
}
s.resetLoadAccounting()
if err := s.startProcess(); err != nil {
return false, fmt.Errorf("llama-server startup failed before projector CPU offload retry: %w; error starting retry: %v", loadErr, err)
}
return true, nil
}
func (s *llamaServerRunner) shouldRetryMMProjCPUOffload(err error) bool {
if err == nil || s.mmprojOffloadOOMRetried || !IsOutOfMemory(err) || len(s.launch.projectors) == 0 {
return false
}
// llama-server --fit can select a text-layer placement that fits before
// mtmd/CLIP allocates the multimodal projector. Retry once with the
// projector on CPU so the scheduler can keep the text model placement.
disabled, _ := s.launch.mmprojOffloadDisabled()
return !disabled
}
func (s *llamaServerRunner) resetLoadAccounting() {
s.memoryMu.Lock()
defer s.memoryMu.Unlock()
s.memTotal = 0
s.memGPU = 0
s.memModelFileBacked = 0
s.memCPUMappedModel = 0
s.gpuLayers = 0
s.gpuLayerOverflow = 0
for k := range s.vramByDevice {
delete(s.vramByDevice, k)
}
for k := range s.systemFreeAtLoad {
delete(s.systemFreeAtLoad, k)
}
if s.status != nil {
s.status.SetLastError("")
}
}
func (s *llamaServerRunner) hasParsedVRAM() bool {
s.memoryMu.RLock()
defer s.memoryMu.RUnlock()
return len(s.vramByDevice) > 0
}
func (s *llamaServerRunner) startLoadTracking(t time.Time) {
if s == nil {
return
}
s.loadTracking.Store(true)
s.noteLoadActivity(t)
}
func (s *llamaServerRunner) stopLoadTracking() {
if s == nil {
return
}
s.loadTracking.Store(false)
}
func (s *llamaServerRunner) noteLoadActivity(t time.Time) {
if s == nil || t.IsZero() {
return
}
if !s.loadTracking.Load() {
return
}
ns := t.UnixNano()
for {
prev := s.loadActivity.Load()
if ns <= prev {
return
}
if s.loadActivity.CompareAndSwap(prev, ns) {
return
}
}
}
func (s *llamaServerRunner) lastLoadActivity() time.Time {
if s == nil {
return time.Time{}
}
if ns := s.loadActivity.Load(); ns > 0 {
return time.Unix(0, ns)
}
return time.Time{}
}
// getServerStatus checks llama-server's /health endpoint.
// llama-server returns {"status":"ok"}, {"status":"loading model"}, or {"status":"error"}.
func (s *llamaServerRunner) getServerStatus(ctx context.Context) (ServerStatus, error) {
if s.cmd.ProcessState != nil {
msg := s.lastErrMsg()
if s.cmd.ProcessState.ExitCode() == -1 {
slog.Warn("llama-server process no longer running", "sys", s.cmd.ProcessState.Sys(), "string", s.cmd.ProcessState)
}
return ServerStatusError, fmt.Errorf("llama-server process no longer running: %s %s", ExitStatus(s.cmd.ProcessState.ExitCode()), msg)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health", s.port), nil)
if err != nil {
return ServerStatusError, fmt.Errorf("error creating health request: %v", err)
}
resp, err := s.httpClient().Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return ServerStatusNotResponding, errors.New("server not responding")
}
if strings.Contains(err.Error(), "connection refused") {
return ServerStatusNotResponding, errors.New("connection refused")
}
return ServerStatusError, fmt.Errorf("health resp: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ServerStatusError, fmt.Errorf("read health response: %w", err)
}
// llama-server returns {"status":"ok"}, {"status":"loading model"}, {"status":"error", ...}
var result struct {
Status string `json:"status"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &result); err != nil {
return ServerStatusError, fmt.Errorf("health unmarshal: %w", err)
}
switch result.Status {
case "ok":
return ServerStatusReady, nil
case "loading model":
return ServerStatusLoadingModel, nil
case "no slot available":
return ServerStatusNoSlotsAvailable, nil
default:
if result.Error != nil {
switch strings.ToLower(strings.TrimSpace(result.Error.Message)) {
case "loading model":
return ServerStatusLoadingModel, nil
case "no slot available":
return ServerStatusNoSlotsAvailable, nil
}
}
return ServerStatusError, fmt.Errorf("llama-server error: %s", string(body))
}
}
func (s *llamaServerRunner) getServerStatusRetry(ctx context.Context) (ServerStatus, error) {
var retries int
for {
status, err := s.getServerStatus(ctx)
if err != nil {
return status, err
}
if status == ServerStatusNoSlotsAvailable {
if retries >= 10 {
return status, fmt.Errorf("no slots available after %d retries", retries)
}
time.Sleep(5 * time.Millisecond)
retries++
continue
}
return status, nil
}
}
func (s *llamaServerRunner) Ping(ctx context.Context) error {
_, err := s.getServerStatus(ctx)
if err != nil {
slog.Debug("llama-server unhealthy", "error", err)
}
return err
}
func (s *llamaServerRunner) WaitUntilRunning(ctx context.Context) error {
s.startLoadTracking(time.Now())
defer s.stopLoadTracking()
stallTimeout := envconfig.LoadTimeout()
lastActivity := s.lastLoadActivity()
if lastActivity.IsZero() {
lastActivity = s.loadStart
}
if lastActivity.IsZero() {
lastActivity = time.Now()
}
loadDeadline := lastActivity.Add(stallTimeout)
slog.Info("waiting for llama-server to start responding")
var lastStatus ServerStatus = -1
for {
select {
case <-ctx.Done():
slog.Warn("client connection closed before llama-server finished loading, aborting load")
return fmt.Errorf("timed out waiting for llama-server to start: %w", ctx.Err())
case <-s.done:
if msg := s.lastErrMsg(); msg != "" {
if s.doneErr == nil {
return fmt.Errorf("llama-server process has terminated: %s", msg)
}
if s.cmd != nil && s.cmd.ProcessState != nil && s.cmd.ProcessState.ExitCode() >= 0 {
return fmt.Errorf("llama-server process has terminated: %s: %s", ExitStatus(s.cmd.ProcessState.ExitCode()), msg)
}
if exit := ExitStatusFromError(s.doneErr); exit.Known() {
return fmt.Errorf("llama-server process has terminated: %s: %s", exit, msg)
}
return fmt.Errorf("llama-server process has terminated: %w: %s", s.doneErr, msg)
}
if s.doneErr == nil {
if s.cmd != nil && s.cmd.ProcessState != nil {
return fmt.Errorf("llama-server process has terminated: %s", ExitStatus(s.cmd.ProcessState.ExitCode()))
}
return errors.New("llama-server process has terminated")
}
if exit := ExitStatusFromError(s.doneErr); exit.Known() {
return fmt.Errorf("llama-server process has terminated: %s", exit)
}
return fmt.Errorf("llama-server process has terminated: %w", s.doneErr)
default:
}
if activity := s.lastLoadActivity(); activity.After(lastActivity) {
lastActivity = activity
loadDeadline = lastActivity.Add(stallTimeout)
}
if time.Now().After(loadDeadline) {
msg := s.lastErrMsg()
return fmt.Errorf("timed out waiting for llama-server to start - %s", msg)
}
if s.cmd.ProcessState != nil {
msg := s.lastErrMsg()
return fmt.Errorf("llama-server process no longer running: %s %s", ExitStatus(s.cmd.ProcessState.ExitCode()), msg)
}
pollCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
status, statusErr := s.getServerStatus(pollCtx)
cancel()
statusChanged := lastStatus != status
if statusChanged && status != ServerStatusReady {
slog.Info("waiting for llama-server to become available", "status", status)
}
if statusChanged && status == ServerStatusLoadingModel {
lastActivity = time.Now()
loadDeadline = lastActivity.Add(stallTimeout)
}
switch status {
case ServerStatusReady:
if s.status != nil {
s.status.SetLastError("")
}
slog.Info(fmt.Sprintf("llama-server started in %0.2f seconds", time.Since(s.loadStart).Seconds()))
return nil
case ServerStatusError:
msg := s.lastErrMsg()
if isRecoverableOutOfMemoryMessage(msg) || isRecoverableOutOfMemory(statusErr) {
lastStatus = status
time.Sleep(time.Millisecond * 250)
continue
}
if IsOutOfMemoryMessage(msg) {
return fmt.Errorf("llama-server reported out-of-memory during startup: %s", msg)
}
if IsOutOfMemory(statusErr) {
return fmt.Errorf("llama-server reported out-of-memory during startup: %w", statusErr)
}
lastStatus = status
time.Sleep(time.Millisecond * 250)
default:
lastStatus = status
time.Sleep(time.Millisecond * 250)
}
}
}
func (s *llamaServerRunner) lastErrMsg() string {
if s.status == nil {
return ""
}
return s.status.LastError()
}
// llamaServerCompletionRequest is the request format for llama-server's POST /completion endpoint.
type llamaServerCompletionRequest struct {
Prompt any `json:"prompt"`
Stream bool `json:"stream"`
CachePrompt bool `json:"cache_prompt"`
NPredict int `json:"n_predict,omitempty"`
NKeep int `json:"n_keep,omitempty"`
Temperature float32 `json:"temperature"`
TopK int `json:"top_k"`
TopP float32 `json:"top_p"`
MinP float32 `json:"min_p"`
Stop []string `json:"stop,omitempty"`
RepeatPenalty float32 `json:"repeat_penalty"`
RepeatLastN int `json:"repeat_last_n"`
FreqPenalty float32 `json:"frequency_penalty"`
PresPenalty float32 `json:"presence_penalty"`
TypicalP float32 `json:"typical_p,omitempty"`
Seed int `json:"seed"`
Grammar string `json:"grammar,omitempty"`
JsonSchema json.RawMessage `json:"json_schema,omitempty"`
NProbs int `json:"n_probs,omitempty"`
PreservedTokens []string `json:"preserved_tokens,omitempty"`
}
func llamaServerPreservedTokens(parserTokens []string, toolCallTag string) []string {
tokens := append([]string{}, parserTokens...)
tokens = append(tokens, llamaServerPreservedTokensForToolTag(toolCallTag)...)
return tokens
}
// llama-server only preserves strings that tokenize to one special token. Some
// Go templates use a parser tag like "[TOOL_CALLS][", where the first segment
// is the special token and the trailing "[" is regular JSON punctuation.
func llamaServerPreservedTokensForToolTag(tag string) []string {
if tag == "" || tag == "{" || tag == "[" {
return nil
}
if token := leadingSpecialTokenCandidate(tag); token != "" {
return []string{token}
}
return []string{tag}
}
func leadingSpecialTokenCandidate(tag string) string {
if len(tag) == 0 {
return ""
}
var close byte
switch tag[0] {
case '[':
close = ']'
case '<':
close = '>'
default:
return ""
}
end := strings.IndexByte(tag, close)
if end <= 0 {
return ""
}
return tag[:end+1]
}
// llamaServerMultimodalPrompt is used when images are present.
// llama-server's /completion endpoint accepts this as the "prompt" field.
type llamaServerMultimodalPrompt struct {
PromptString string `json:"prompt_string"`
MultimodalData []string `json:"multimodal_data"`
}
// llamaServerCompletionResponse is the response format from llama-server's /completion endpoint.
type llamaServerCompletionResponse struct {
Content string `json:"content"`
Stop bool `json:"stop"`
StopType string `json:"stop_type"`
Timings llamaServerTimings `json:"timings"`
CompletionProbabilities []llamaServerTokenProb `json:"completion_probabilities"`
}
type llamaServerChatChoice struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
Logprobs struct {
Content []llamaServerTokenProb `json:"content"`
} `json:"logprobs"`
}
type llamaServerChatResponse struct {
Choices []llamaServerChatChoice `json:"choices"`
Timings llamaServerTimings `json:"timings"`
Error any `json:"error"`
}
type llamaServerTimings struct {
CacheN int `json:"cache_n"`
PromptN int `json:"prompt_n"`
PromptMS float64 `json:"prompt_ms"`
PredictN int `json:"predicted_n"`
PredictMS float64 `json:"predicted_ms"`
}
func (t llamaServerTimings) promptEvalCount() int {
return t.CacheN + t.PromptN
}
type llamaServerApplyTemplateResponse struct {
Prompt string `json:"prompt"`
Error any `json:"error"`
}
type llamaServerTokenProb struct {
Token string `json:"token"`
Logprob float64 `json:"logprob"`
Prob float64 `json:"prob"`
TopLogprobs []llamaServerTokenProb `json:"top_logprobs"`
TopProbs []llamaServerTokenProb `json:"top_probs"`
}
func (s *llamaServerRunner) Completion(ctx context.Context, req CompletionRequest, fn func(CompletionResponse)) error {
slog.Debug("llama-server completion request", "media", len(req.Media), "prompt_len", len(req.Prompt))
if req.Options == nil {
opts := api.DefaultOptions()
req.Options = &opts
}
if err := s.sem.Acquire(ctx, 1); err != nil {
if errors.Is(err, context.Canceled) {
slog.Info("aborting completion request due to client closing the connection")
}
return err
}
defer s.sem.Release(1)
req.Options.NumPredict = boundedNumPredict(req.Options.NumPredict, s.options.NumCtx)
status, err := s.getServerStatusRetry(ctx)
if err != nil {
return err
} else if status != ServerStatusReady {
return fmt.Errorf("unexpected server status: %s", status)
}
prompt, err := s.completionPromptForRequest(ctx, req)
if err != nil {
return err
}
// Build the llama-server request
lsReq := llamaServerCompletionRequest{
Prompt: prompt,
Stream: true,
CachePrompt: true,
NPredict: req.Options.NumPredict,
NKeep: req.Options.NumKeep,
Temperature: req.Options.Temperature,
TopK: req.Options.TopK,
TopP: req.Options.TopP,
MinP: req.Options.MinP,
Stop: req.Options.Stop,
RepeatPenalty: req.Options.RepeatPenalty,
RepeatLastN: req.Options.RepeatLastN,
FreqPenalty: req.Options.FrequencyPenalty,
PresPenalty: req.Options.PresencePenalty,
TypicalP: req.Options.TypicalP,
Seed: req.Options.Seed,
PreservedTokens: llamaServerPreservedTokens(req.PreservedTokens, req.ToolCallTag),
}
if req.Logprobs {
lsReq.NProbs = max(req.TopLogprobs, 1)
}
// Handle format: pass JSON schema directly to llama-server, or use grammar
if len(req.Format) > 0 {
switch string(req.Format) {
case `null`, `""`:
// not set
case `"json"`:
lsReq.Grammar = grammarJSON
default:
if req.Format[0] == '{' {
lsReq.JsonSchema = req.Format
} else {
return fmt.Errorf("invalid format: %q; expected \"json\" or a valid JSON Schema object", req.Format)
}
}
} else if req.Grammar != "" {
lsReq.Grammar = req.Grammar
}
// Convert media: replace Ollama's stable [img-N] markers with the per-process
// llama-server media marker and package the matching payloads as base64.
if len(req.Media) > 0 {
promptStr := lsReq.Prompt.(string)
var mediaData []string
for _, media := range req.Media {
marker := fmt.Sprintf("[img-%d]", media.ID)
promptStr = strings.Replace(promptStr, marker, s.llamaServerMediaMarker(), 1)
mediaData = append(mediaData, base64.StdEncoding.EncodeToString(media.Data))
}
lsReq.Prompt = llamaServerMultimodalPrompt{
PromptString: promptStr,
MultimodalData: mediaData,
}
}
buffer := &bytes.Buffer{}
enc := json.NewEncoder(buffer)
enc.SetEscapeHTML(false)
if err := enc.Encode(lsReq); err != nil {
return fmt.Errorf("failed to marshal completion request: %v", err)
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d/completion", s.port)
serverReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, buffer)
if err != nil {
return fmt.Errorf("error creating completion request: %v", err)
}
serverReq.Header.Set("Content-Type", "application/json")
res, err := s.httpClient().Do(serverReq)
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
slog.Error("llama-server completion error", "error", err)
if msg := s.lastErrMsg(); msg != "" {
return fmt.Errorf("model runner has unexpectedly stopped, this may be due to resource limitations or an internal error, check ollama server logs for details: %s", msg)
}
return errors.New("model runner has unexpectedly stopped, this may be due to resource limitations or an internal error, check ollama server logs for details")
}
defer res.Body.Close()
if res.StatusCode >= 400 {
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed reading llama-server error response: %w", err)
}
return api.StatusError{StatusCode: res.StatusCode, ErrorMessage: s.statusErrorMessage(bodyBytes)}
}
// Parse SSE stream from llama-server. Delay the final Done callback until
// after the response body is closed because routes may tokenize from that
// callback to build the final Generate context.
scanner := bufio.NewScanner(res.Body)
buf := make([]byte, 0, llamaServerStreamInitialBufferSize)
scanner.Buffer(buf, llamaServerStreamMaxBufferSize)
var lastToken string
var tokenRepeat int
var finalResp CompletionResponse
var hasFinalResp bool
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
line := scanner.Bytes()
if len(line) == 0 {
continue
}
if bytes.HasPrefix(line, []byte(":")) {
continue
}
evt, ok := bytes.CutPrefix(line, []byte("data: "))
if !ok {
evt = line
}
var lsResp llamaServerCompletionResponse
if err := json.Unmarshal(evt, &lsResp); err != nil {
return fmt.Errorf("error unmarshalling llama-server response: %v", err)
}
// Token repeat detection
switch {
case strings.TrimSpace(lsResp.Content) == lastToken:
tokenRepeat++
default:
lastToken = strings.TrimSpace(lsResp.Content)
tokenRepeat = 0
}
if tokenRepeat > 30 {
slog.Debug("prediction aborted, token repeat limit reached")
return ctx.Err()
}
if lsResp.Content != "" && !lsResp.Stop {
resp := CompletionResponse{
Content: lsResp.Content,
}
resp.Logprobs = convertLogprobs(lsResp.CompletionProbabilities, req.TopLogprobs > 0)
fn(resp)
}
if lsResp.Stop {
doneReason := DoneReasonStop
if lsResp.StopType == "limit" {
doneReason = DoneReasonLength
}
finalResp = CompletionResponse{
Content: lsResp.Content,
Done: true,
DoneReason: doneReason,
PromptEvalCount: lsResp.Timings.promptEvalCount(),
PromptEvalDuration: time.Duration(lsResp.Timings.PromptMS * float64(time.Millisecond)),
EvalCount: lsResp.Timings.PredictN,
EvalDuration: time.Duration(lsResp.Timings.PredictMS * float64(time.Millisecond)),
}
hasFinalResp = true
}
}
if hasFinalResp {
break
}
}
if hasFinalResp {
for scanner.Scan() {
}
if err := scanner.Err(); err != nil {
if err := llamaServerStreamLimitError("response", err); err != nil {
return err
}
return fmt.Errorf("error reading llama-server response: %v", err)
}
if err := res.Body.Close(); err != nil {
return fmt.Errorf("error closing llama-server response: %v", err)
}
fn(finalResp)
return nil
}
if err := scanner.Err(); err != nil {
if err := llamaServerStreamLimitError("response", err); err != nil {
return err
}
if strings.Contains(err.Error(), "unexpected EOF") || strings.Contains(err.Error(), "forcibly closed") {
s.Close()
msg := s.lastErrMsg()
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("an error was encountered while running the model: %s", msg)
}
return fmt.Errorf("error reading llama-server response: %v", err)
}
return nil
}
func llamaServerStreamLimitError(label string, err error) error {
if !strings.Contains(err.Error(), "token too long") {
return nil
}
return fmt.Errorf("llama-server %s stream event exceeded %d MB limit", label, llamaServerStreamMaxBufferSize/(1000*1000))
}
func (s *llamaServerRunner) statusErrorMessage(body []byte) string {
errMsg := strings.TrimSpace(string(body))
statusMsg := s.lastErrMsg()
if statusMsg == "" {
return errMsg
}
if IsOutOfMemoryMessage(statusMsg) && !strings.Contains(strings.ToLower(errMsg), strings.ToLower(statusMsg)) {
return strings.TrimSpace(errMsg + "\n" + statusMsg)
}
return errMsg
}
// convertLogprobs converts llama-server's completion_probabilities to Ollama's Logprob format.
// includeTop controls whether top alternatives are included in the output.
func convertLogprobs(probs []llamaServerTokenProb, includeTop bool) []Logprob {
if len(probs) == 0 {
return nil
}
result := make([]Logprob, len(probs))
for i, p := range probs {
// llama-server uses "logprob" for log-probs mode, "prob" for sampling-probs mode
logprob := p.Logprob
if logprob == 0 && p.Prob != 0 {
logprob = p.Prob // Use whichever is set
}
result[i] = Logprob{
TokenLogprob: TokenLogprob{
Token: p.Token,
Logprob: logprob,
},
}
if !includeTop {
continue
}
// Convert top logprobs (could be top_logprobs or top_probs depending on mode)
topProbs := p.TopLogprobs
if len(topProbs) == 0 {
topProbs = p.TopProbs
}
for _, tp := range topProbs {
tl := tp.Logprob
if tl == 0 && tp.Prob != 0 {
tl = tp.Prob
}
result[i].TopLogprobs = append(result[i].TopLogprobs, TokenLogprob{
Token: tp.Token,
Logprob: tl,
})
}
}
return result
}
func (s *llamaServerRunner) ApplyChatTemplate(ctx context.Context, req ChatRequest) (string, error) {
data, err := s.llamaServerChatRequest(req, false)
if err != nil {
return "", err
}
body, err := json.Marshal(data)
if err != nil {
return "", fmt.Errorf("failed to marshal chat template request: %v", err)
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d/apply-template", s.port)
serverReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("error creating chat template request: %v", err)
}
serverReq.Header.Set("Content-Type", "application/json")
res, err := s.httpClient().Do(serverReq)
if err != nil {
if errors.Is(err, context.Canceled) {
return "", err
}
return "", fmt.Errorf("llama-server apply-template error: %w", err)
}
defer res.Body.Close()
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("failed reading llama-server template response: %w", err)
}
if res.StatusCode >= 400 {
return "", api.StatusError{StatusCode: res.StatusCode, ErrorMessage: s.statusErrorMessage(bodyBytes)}
}
var lsResp llamaServerApplyTemplateResponse
if err := json.Unmarshal(bodyBytes, &lsResp); err != nil {
return "", fmt.Errorf("error unmarshalling llama-server template response: %v", err)
}
if lsResp.Error != nil {
return "", fmt.Errorf("llama-server template error: %v", lsResp.Error)
}
return lsResp.Prompt, nil
}
func (s *llamaServerRunner) Chat(ctx context.Context, req ChatRequest, fn func(ChatResponse)) error {
slog.Debug("llama-server chat request", "messages", len(req.Messages), "tools", len(req.Tools))
if req.Options == nil {
opts := api.DefaultOptions()
req.Options = &opts
}
if err := s.sem.Acquire(ctx, 1); err != nil {
if errors.Is(err, context.Canceled) {
slog.Info("aborting chat request due to client closing the connection")
}
return err
}
defer s.sem.Release(1)
req.Options.NumPredict = boundedNumPredict(req.Options.NumPredict, s.options.NumCtx)
status, err := s.getServerStatusRetry(ctx)
if err != nil {
return err
} else if status != ServerStatusReady {
return fmt.Errorf("unexpected server status: %s", status)
}
lsReq, err := s.llamaServerChatRequest(req, true)
if err != nil {
return err
}
buffer := &bytes.Buffer{}
enc := json.NewEncoder(buffer)
enc.SetEscapeHTML(false)
if err := enc.Encode(lsReq); err != nil {
return fmt.Errorf("failed to marshal chat request: %v", err)
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d/v1/chat/completions", s.port)
serverReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, buffer)
if err != nil {
return fmt.Errorf("error creating chat request: %v", err)
}
serverReq.Header.Set("Content-Type", "application/json")
res, err := s.httpClient().Do(serverReq)
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
slog.Error("llama-server chat error", "error", err)
if msg := s.lastErrMsg(); msg != "" {
return fmt.Errorf("model runner has unexpectedly stopped, this may be due to resource limitations or an internal error, check ollama server logs for details: %s", msg)
}
return errors.New("model runner has unexpectedly stopped, this may be due to resource limitations or an internal error, check ollama server logs for details")
}
defer res.Body.Close()
if res.StatusCode >= 400 {
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed reading llama-server error response: %w", err)
}
return api.StatusError{StatusCode: res.StatusCode, ErrorMessage: s.statusErrorMessage(bodyBytes)}
}
scanner := bufio.NewScanner(res.Body)
buf := make([]byte, 0, llamaServerStreamInitialBufferSize)
scanner.Buffer(buf, llamaServerStreamMaxBufferSize)
toolCalls := map[int]*llamaServerToolCallAccumulator{}
var finalResp ChatResponse
var hasFinalResp bool
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
line := scanner.Bytes()
if len(line) == 0 {
continue
}
if bytes.HasPrefix(line, []byte(":")) {
continue
}
evt, ok := bytes.CutPrefix(line, []byte("data: "))
if !ok {
evt = line
}
if bytes.Equal(evt, []byte("[DONE]")) {
continue
}
var lsResp llamaServerChatResponse
if err := json.Unmarshal(evt, &lsResp); err != nil {
return fmt.Errorf("error unmarshalling llama-server chat response: %v", err)
}
if lsResp.Error != nil {
return fmt.Errorf("llama-server chat error: %v", lsResp.Error)
}
if len(lsResp.Choices) == 0 {
continue
}
choice := lsResp.Choices[0]
resp := ChatResponse{
Message: api.Message{
Role: "assistant",
Content: choice.Delta.Content,
Thinking: choice.Delta.ReasoningContent,
},
Logprobs: convertLogprobs(choice.Logprobs.Content, req.TopLogprobs > 0),
}
for _, tc := range choice.Delta.ToolCalls {
acc := toolCalls[tc.Index]
if acc == nil {
acc = &llamaServerToolCallAccumulator{index: tc.Index}
toolCalls[tc.Index] = acc
}
acc.id += tc.ID
if tc.Function.Name != "" {
acc.name += tc.Function.Name
}
acc.arguments += tc.Function.Arguments
}
if choice.FinishReason != nil {
doneReason := DoneReasonStop
if *choice.FinishReason == "length" {
doneReason = DoneReasonLength
}
resp.Done = true
resp.DoneReason = doneReason
resp.PromptEvalCount = lsResp.Timings.promptEvalCount()
resp.PromptEvalDuration = time.Duration(lsResp.Timings.PromptMS * float64(time.Millisecond))
resp.EvalCount = lsResp.Timings.PredictN
resp.EvalDuration = time.Duration(lsResp.Timings.PredictMS * float64(time.Millisecond))
toolCalls, err := accumulatedToolCalls(toolCalls)
if err != nil {
return err
}
resp.Message.ToolCalls = toolCalls
finalResp = resp
hasFinalResp = true
break
}
if resp.Message.Content != "" || resp.Message.Thinking != "" || len(resp.Logprobs) > 0 {
fn(resp)
}
}
if hasFinalResp {
break
}
}
if hasFinalResp {
for scanner.Scan() {
}
if err := scanner.Err(); err != nil {
if err := llamaServerStreamLimitError("chat response", err); err != nil {
return err
}
return fmt.Errorf("error reading llama-server chat response: %v", err)
}
if err := res.Body.Close(); err != nil {
return fmt.Errorf("error closing llama-server chat response: %v", err)
}
fn(finalResp)
return nil
}
if err := scanner.Err(); err != nil {
if err := llamaServerStreamLimitError("chat response", err); err != nil {
return err
}
if strings.Contains(err.Error(), "unexpected EOF") || strings.Contains(err.Error(), "forcibly closed") {
s.Close()
msg := s.lastErrMsg()
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("an error was encountered while running the model: %s", msg)
}
return fmt.Errorf("error reading llama-server chat response: %v", err)
}
return nil
}
type llamaServerToolCallAccumulator struct {
index int
id string
name string
arguments string
}
type llamaServerChatToolCall struct {
ID string `json:"id,omitempty"`
Index int `json:"index"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
func accumulatedToolCalls(accs map[int]*llamaServerToolCallAccumulator) ([]api.ToolCall, error) {
if len(accs) == 0 {
return nil, nil
}
maxIndex := 0
for index := range accs {
maxIndex = max(maxIndex, index)
}
toolCalls := make([]api.ToolCall, 0, len(accs))
for index := 0; index <= maxIndex; index++ {
acc := accs[index]
if acc == nil {
continue
}
var args api.ToolCallFunctionArguments
if strings.TrimSpace(acc.arguments) != "" {
if err := json.Unmarshal([]byte(acc.arguments), &args); err != nil {
return nil, fmt.Errorf("llama-server returned invalid tool call arguments for %q: %w", acc.name, err)
}
}
toolCalls = append(toolCalls, api.ToolCall{
ID: acc.id,
Function: api.ToolCallFunction{
Index: acc.index,
Name: acc.name,
Arguments: args,
},
})
}
return toolCalls, nil
}
func (s *llamaServerRunner) llamaServerChatRequest(req ChatRequest, stream bool) (map[string]any, error) {
if req.Options == nil {
opts := api.DefaultOptions()
req.Options = &opts
}
messages := make([]map[string]any, 0, len(req.Messages))
for _, msg := range req.Messages {
converted, err := llamaServerChatMessage(MessageFromAPI(msg))
if err != nil {
return nil, err
}
messages = append(messages, converted)
}
body := map[string]any{
"messages": messages,
"stream": stream,
"cache_prompt": true,
"n_predict": req.Options.NumPredict,
"n_keep": req.Options.NumKeep,
"temperature": req.Options.Temperature,
"top_k": req.Options.TopK,
"top_p": req.Options.TopP,
"min_p": req.Options.MinP,
"stop": req.Options.Stop,
"repeat_penalty": req.Options.RepeatPenalty,
"repeat_last_n": req.Options.RepeatLastN,
"frequency_penalty": req.Options.FrequencyPenalty,
"presence_penalty": req.Options.PresencePenalty,
"typical_p": req.Options.TypicalP,
"seed": req.Options.Seed,
}
if len(req.Tools) > 0 {
body["tools"] = req.Tools
}
if req.Logprobs {
body["logprobs"] = true
body["top_logprobs"] = max(req.TopLogprobs, 1)
}
if kwargs := llamaServerChatTemplateKwargs(req.Think); kwargs != nil {
body["chat_template_kwargs"] = kwargs
}
if format, err := llamaServerChatResponseFormat(req.Format); err != nil {
return nil, err
} else if format != nil {
body["response_format"] = format
}
return body, nil
}
func llamaServerChatTemplateKwargs(think *api.ThinkValue) map[string]any {
if think == nil {
return nil
}
kwargs := map[string]any{
"enable_thinking": think.Bool(),
}
if think.IsString() {
if effort := think.String(); effort != "" {
kwargs["reasoning_effort"] = effort
}
}
return kwargs
}
func llamaServerChatMessage(msg Message) (map[string]any, error) {
converted := map[string]any{
"role": msg.Role,
}
if msg.ToolCallID != "" {
converted["tool_call_id"] = msg.ToolCallID
}
if msg.ToolName != "" {
converted["name"] = msg.ToolName
}
if len(msg.ToolCalls) > 0 {
toolCalls, err := llamaServerChatToolCalls(msg.ToolCalls)
if err != nil {
return nil, err
}
converted["tool_calls"] = toolCalls
}
if len(msg.Media) == 0 {
converted["content"] = msg.Content
return converted, nil
}
parts := make([]map[string]any, 0, len(msg.Media)+1)
if msg.Content != "" {
parts = append(parts, map[string]any{
"type": "text",
"text": msg.Content,
})
}
for _, media := range msg.Media {
parts = append(parts, llamaServerChatMediaPart(media))
}
converted["content"] = parts
return converted, nil
}
func llamaServerChatMediaPart(media MediaData) map[string]any {
encoded := base64.StdEncoding.EncodeToString(media.Data)
if format, ok := AudioFormat(media.Data); ok {
return map[string]any{
"type": "input_audio",
"input_audio": map[string]any{
"data": encoded,
"format": format,
},
}
}
mime := http.DetectContentType(media.Data)
if !strings.HasPrefix(mime, "image/") {
mime = "image/jpeg"
}
return map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + mime + ";base64," + encoded,
},
}
}
func llamaServerChatToolCalls(tcs []api.ToolCall) ([]llamaServerChatToolCall, error) {
toolCalls := make([]llamaServerChatToolCall, len(tcs))
for i, tc := range tcs {
toolCalls[i].ID = tc.ID
toolCalls[i].Index = tc.Function.Index
toolCalls[i].Type = "function"
toolCalls[i].Function.Name = tc.Function.Name
args, err := json.Marshal(tc.Function.Arguments)
if err != nil {
return nil, fmt.Errorf("failed to marshal tool call arguments for %q: %w", tc.Function.Name, err)
}
toolCalls[i].Function.Arguments = string(args)
}
return toolCalls, nil
}
func llamaServerChatResponseFormat(format json.RawMessage) (map[string]any, error) {
if len(format) == 0 {
return nil, nil
}
switch string(format) {
case `null`, `""`:
return nil, nil
case `"json"`:
return map[string]any{"type": "json_object"}, nil
default:
if format[0] != '{' {
return nil, fmt.Errorf("invalid format: %q; expected \"json\" or a valid JSON Schema object", format)
}
var schema map[string]any
if err := json.Unmarshal(format, &schema); err != nil {
return nil, fmt.Errorf("invalid format: %q; expected \"json\" or a valid JSON Schema object", format)
}
return map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "schema",
"schema": schema,
},
}, nil
}
}
func (s *llamaServerRunner) Embedding(ctx context.Context, input string) ([]float32, int, error) {
if err := s.sem.Acquire(ctx, 1); err != nil {
return nil, 0, err
}
defer s.sem.Release(1)
status, err := s.getServerStatusRetry(ctx)
if err != nil {
return nil, 0, err
} else if status != ServerStatusReady {
return nil, 0, fmt.Errorf("unexpected server status: %s", status)
}
// Use "input" field (not "content") to get the OAI-compatible response format
// which includes tokens_evaluated for prompt token counting
req := map[string]any{"input": input}
if s.rawEmbeddings {
req["embd_normalize"] = -1
}
data, err := json.Marshal(req)
if err != nil {
return nil, 0, fmt.Errorf("error marshaling embed data: %w", err)
}
// Use /v1/embeddings (OAI-compatible) to get tokens_evaluated in the response
r, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/v1/embeddings", s.port), bytes.NewBuffer(data))
if err != nil {
return nil, 0, fmt.Errorf("error creating embed request: %w", err)
}
r.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(r)
if err != nil {
return nil, 0, fmt.Errorf("do embedding request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("error reading embed response: %w", err)
}
if resp.StatusCode >= 400 {
statusCode, errMsg := normalizeEmbeddingError(resp.StatusCode, body)
return nil, 0, api.StatusError{StatusCode: statusCode, ErrorMessage: errMsg}
}
// With "input" field, llama-server returns OAI-compatible format:
// {"data": [{"embedding": [0.1, ...], "tokens_evaluated": N}], "usage": {"prompt_tokens": N}}
// With "content" field, it returns:
// [{"embedding": [[0.1, ...]], "index": 0}]
var oaiResp struct {
Data []struct {
Embedding json.RawMessage `json:"embedding"`
TokensEvaluated int `json:"tokens_evaluated"`
} `json:"data"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(body, &oaiResp); err == nil && len(oaiResp.Data) > 0 {
var embedding []float32
if err := json.Unmarshal(oaiResp.Data[0].Embedding, &embedding); err != nil {
return nil, 0, fmt.Errorf("unmarshal embedding values: %w", err)
}
promptTokens := oaiResp.Usage.PromptTokens
if promptTokens == 0 {
promptTokens = oaiResp.Data[0].TokensEvaluated
}
return embedding, promptTokens, nil
}
// Fallback: non-OAI array format [{"embedding": [[0.1, ...]], "index": 0}]
var results []struct {
Embedding json.RawMessage `json:"embedding"`
}
if err := json.Unmarshal(body, &results); err != nil {
return nil, 0, fmt.Errorf("unmarshal embedding response: %w", err)
}
if len(results) == 0 {
return nil, 0, fmt.Errorf("empty embedding response")
}
var embedding []float32
if err := json.Unmarshal(results[0].Embedding, &embedding); err != nil {
var nested [][]float32
if err2 := json.Unmarshal(results[0].Embedding, &nested); err2 != nil {
return nil, 0, fmt.Errorf("unmarshal embedding values: %w (also tried nested: %w)", err, err2)
}
if len(nested) > 0 {
embedding = nested[0]
}
}
return embedding, 0, nil
}
func normalizeEmbeddingError(statusCode int, body []byte) (int, string) {
raw := strings.TrimSpace(string(body))
errMsg := extractLlamaServerErrorMessage(body)
if errMsg == "" {
errMsg = raw
}
if isEmbeddingInputLimitError(errMsg) || isEmbeddingInputLimitError(raw) {
return http.StatusBadRequest, "the input length exceeds the context length"
}
return statusCode, errMsg
}
func extractLlamaServerErrorMessage(body []byte) string {
var resp struct {
Error json.RawMessage `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil || len(resp.Error) == 0 {
return ""
}
var msg string
if err := json.Unmarshal(resp.Error, &msg); err == nil {
return strings.TrimSpace(msg)
}
var nested struct {
Message string `json:"message"`
}
if err := json.Unmarshal(resp.Error, &nested); err == nil {
return strings.TrimSpace(nested.Message)
}
return ""
}
func isEmbeddingInputLimitError(errMsg string) bool {
msg := strings.ToLower(errMsg)
return strings.Contains(msg, "too large") ||
strings.Contains(msg, "context size") ||
strings.Contains(msg, "context length") ||
strings.Contains(msg, "physical batch size") ||
strings.Contains(msg, "exceeds the available context")
}
func (s *llamaServerRunner) tokenize(ctx context.Context, content any, addSpecial bool, parseSpecial *bool) ([]int, error) {
req := struct {
Content any `json:"content"`
AddSpecial bool `json:"add_special,omitempty"`
ParseSpecial *bool `json:"parse_special,omitempty"`
}{
Content: content,
AddSpecial: addSpecial,
ParseSpecial: parseSpecial,
}
data, err := json.Marshal(req)
if err != nil {
return nil, err
}
r, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/tokenize", s.port), bytes.NewBuffer(data))
if err != nil {
return nil, err
}
r.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(r)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("tokenize error: %s", body)
}
var result struct {
Tokens []int `json:"tokens"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result.Tokens, nil
}
// Tokenize calls llama-server's /tokenize endpoint.
func (s *llamaServerRunner) Tokenize(ctx context.Context, content string) ([]int, error) {
return s.tokenize(ctx, content, false, nil)
}
// Detokenize calls llama-server's /detokenize endpoint.
func (s *llamaServerRunner) Detokenize(ctx context.Context, tokens []int) (string, error) {
data, err := json.Marshal(map[string][]int{"tokens": tokens})
if err != nil {
return "", err
}
r, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/detokenize", s.port), bytes.NewBuffer(data))
if err != nil {
return "", err
}
r.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(r)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("detokenize error: %s", body)
}
var result struct {
Content string `json:"content"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
return result.Content, nil
}
func (s *llamaServerRunner) Close() error {
return s.stopProcess()
}
func (s *llamaServerRunner) stopProcess() error {
if s.cmd != nil && s.cmd.Process != nil {
if s.cmd.ProcessState != nil {
return nil
}
slog.Debug("stopping llama-server", "pid", s.Pid())
if err := s.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
if s.done != nil {
slog.Debug("waiting for llama-server to exit", "pid", s.Pid())
<-s.done
}
slog.Debug("llama-server stopped", "pid", s.Pid())
}
return nil
}
// GetDeviceInfos returns device info for GPUs used by this runner, with FreeMemory
// updated to reflect actual usage. Uses the minimum of:
// - Our accounting: TotalMemory minus tracked VRAM allocations
// - System-reported: free VRAM from llama-server at load time minus our allocations
//
// The min-of-two approach handles both our own usage (accurate) and external
// consumers (system-reported, may be optimistic on some platforms).
func (s *llamaServerRunner) GetDeviceInfos(ctx context.Context) []ml.DeviceInfo {
if len(s.gpus) == 0 {
return nil
}
s.memoryMu.RLock()
defer s.memoryMu.RUnlock()
infos := make([]ml.DeviceInfo, len(s.gpus))
for i, gpu := range s.gpus {
infos[i] = gpu
used := s.vramByDevice[gpu.Name]
// Our accounting: total minus what we allocated
var accountedFree uint64
if used < gpu.TotalMemory {
accountedFree = gpu.TotalMemory - used
}
// System-reported: what the GPU said was free at load time, minus what
// we've allocated since. This captures external consumers on platforms
// where the driver reports accurately.
systemFree := accountedFree // default to our accounting
if sysFree, ok := s.systemFreeAtLoad[gpu.Name]; ok {
if used < sysFree {
systemFree = sysFree - used
} else {
systemFree = 0
}
}
// Take the minimum — never optimistic
infos[i].FreeMemory = min(accountedFree, systemFree)
}
return infos
}
// MemorySize returns total and GPU memory usage parsed from llama-server's
// post-load log output. Full model-layer offload is reported as 100% GPU.
func (s *llamaServerRunner) MemorySize() (total, vram uint64) {
s.memoryMu.RLock()
memTotal := s.memTotal
memGPU := s.memGPU
memModelFileBacked := s.memModelFileBacked
memCPUMappedModel := s.memCPUMappedModel
totalLayers := s.totalLayers
gpuLayers := s.gpuLayers
gpuLayerOverflow := s.gpuLayerOverflow
s.memoryMu.RUnlock()
if memTotal > 0 {
total, vram = memTotal, memGPU
// With mmap, llama-server reports each CPU_Mapped model buffer as the
// file-offset span of its CPU-resident tensors. During partial offload
// that span covers nearly the whole file (the first and last tensors
// stay on CPU), re-counting weights already held in device buffers.
// Only buffers that mirror the on-disk file can overlap this way;
// repacked copies such as CPU_REPACK are separate real allocations and
// must be left intact. Weights cannot exceed the model file on disk, so
// trim that overlap from the mmap-backed (reclaimable page cache) portion.
if memCPUMappedModel > 0 {
if info, err := os.Stat(s.modelPath); err == nil && memModelFileBacked > uint64(info.Size()) {
total -= min(memCPUMappedModel, memModelFileBacked-uint64(info.Size()))
}
}
if totalLayers > 0 && gpuLayers >= totalLayers && gpuLayerOverflow == 0 {
total = vram
}
return total, vram
}
// Fallback: use model file size as a rough proxy
slog.Debug("llama-server buffer sizes not available, falling back to file size estimate", "model", s.modelPath)
if info, err := os.Stat(s.modelPath); err == nil {
total = uint64(info.Size())
vram = total
}
return total, vram
}
// PredictServerVRAM estimates VRAM usage for a model without spawning llama-server.
// Uses model file size as a proxy for weights plus a rough KV cache estimate.
// This is intentionally conservative — it overestimates to avoid VRAM contention.
func PredictServerVRAM(modelPath string, f *ggml.GGML, numCtx int) uint64 {
var weights uint64
if info, err := os.Stat(modelPath); err == nil {
weights = uint64(info.Size())
}
// KV cache: 2 (K+V) * layers * kv_heads * head_dim * context * 2 bytes (f16)
layers := f.KV().BlockCount()
kvHeads := f.KV().HeadCountKVMin()
if kvHeads == 0 {
kvHeads = 1
}
headDim := uint64(0)
if f.KV().HeadCountMax() > 0 {
headDim = f.KV().EmbeddingLength() / f.KV().HeadCountMax()
}
kvCache := 2 * layers * kvHeads * headDim * uint64(numCtx) * 2
return weights + kvCache
}
// memoryParsingWriter wraps an io.Writer and parses llama-server log output
// for buffer size lines. It updates the runner's per-device VRAM tracking.
//
// Parsed line formats (all backends):
//
// CUDA0 model buffer size = 852.89 MiB
// CUDA0 KV buffer size = 1920.00 MiB
// CUDA0 compute buffer size = 378.04 MiB
// CPU_Mapped model buffer size = 308.23 MiB
// CUDA_Host compute buffer size = 268.05 MiB
// MTL0_Mapped model buffer size = 1918.35 MiB
// ROCm0 model buffer size = 1918.35 MiB
type memoryParsingWriter struct {
inner io.Writer
runner *llamaServerRunner
buffers map[memoryBufferKey]memoryBuffer
}
type memoryBufferKey struct {
component string
backend string
kind string
}
type memoryBuffer struct {
bytes uint64
}
// deviceFreeRegex matches per-device free VRAM reported at model load time:
//
// using device CUDA0 (NVIDIA GeForce RTX 4060 Ti) (0000:01:00.0) - 15221 MiB free
// using device MTL0 (Apple M5 Max) (unknown id) - 110100 MiB free
// using device ROCm0 (AMD Radeon RX 6800) (0000:06:00.0) - 16196 MiB free
var deviceFreeRegex = regexp.MustCompile(`using device (\S+)\s+\(.*\)\s+-\s+(\d+)\s+MiB free`)
// bufferSizeRegex matches llama-server buffer size lines and captures the
// component so repeated fit/probe values can be replaced by the final load.
var bufferSizeRegex = regexp.MustCompile(`(?m)(?:^|\n)[^\n:]*?([A-Za-z_][A-Za-z0-9_]*):\s+(\S+)\s+(model|KV|compute|output|RS)\s+buffer size\s*=\s*([\d.]+)\s*MiB`)
var (
offloadedLayersRegex = regexp.MustCompile(`offloaded\s+(\d+)/(\d+)\s+layers to GPU`)
fitOverflowingLayersRegex = regexp.MustCompile(`common_params_fit_impl:\s+-\s+.+:\s+\d+\s+layers\s+\(\s*(\d+)\s+overflowing\)`)
)
// isGPUBuffer returns true if the backend buffer name represents GPU memory.
// CPU, BLAS, and host-pinned buffers (*_Host) are not GPU memory.
// Device-mapped buffers (e.g., MTL0_Mapped) ARE GPU memory — they're model
// weights in device-accessible memory. Only CPU_Mapped is CPU memory.
func isGPUBuffer(name string) bool {
if name == "CPU" || name == "BLAS" || strings.HasPrefix(name, "CPU_") {
return false
}
if strings.HasSuffix(name, "_Host") {
return false
}
return true
}
// deviceName returns the base device name for per-device VRAM tracking.
// Strips suffixes like _Mapped, _REPACK so that e.g. "MTL0_Mapped" is
// tracked under "MTL0" alongside "MTL0 KV buffer" and "MTL0 compute buffer".
func deviceName(backendName string) string {
for _, suffix := range []string{"_Mapped", "_REPACK", "_Private"} {
if strings.HasSuffix(backendName, suffix) {
return strings.TrimSuffix(backendName, suffix)
}
}
return backendName
}
func (w *memoryParsingWriter) Write(b []byte) (int, error) {
if w.runner != nil {
if len(b) > 0 && w.runner.loadTracking.Load() {
w.runner.noteLoadActivity(time.Now())
}
func() {
w.runner.memoryMu.Lock()
defer w.runner.memoryMu.Unlock()
if match := deviceFreeRegex.FindSubmatch(b); match != nil {
devName := string(match[1])
if mib, err := strconv.ParseUint(string(match[2]), 10, 64); err == nil {
w.runner.systemFreeAtLoad[devName] = mib * 1024 * 1024
}
}
for _, match := range offloadedLayersRegex.FindAllSubmatch(b, -1) {
loaded, loadedErr := strconv.ParseUint(string(match[1]), 10, 64)
total, totalErr := strconv.ParseUint(string(match[2]), 10, 64)
if loadedErr == nil && totalErr == nil {
w.runner.gpuLayers = loaded
w.runner.totalLayers = total
}
}
for _, match := range fitOverflowingLayersRegex.FindAllSubmatch(b, -1) {
overflowing, err := strconv.ParseUint(string(match[1]), 10, 64)
if err == nil && overflowing > 0 {
w.runner.gpuLayerOverflow += int(overflowing)
}
}
for _, match := range bufferSizeRegex.FindAllSubmatch(b, -1) {
backendName := string(match[2])
if mib, err := strconv.ParseFloat(string(match[4]), 64); err == nil {
if w.buffers == nil {
w.buffers = make(map[memoryBufferKey]memoryBuffer)
}
w.buffers[memoryBufferKey{
component: string(match[1]),
backend: backendName,
kind: string(match[3]),
}] = memoryBuffer{bytes: uint64(mib * 1024 * 1024)}
w.updateRunnerMemoryLocked()
}
}
}()
}
return w.inner.Write(b)
}
func (w *memoryParsingWriter) updateRunnerMemoryLocked() {
var total, gpu, modelFileBacked, cpuMappedModel uint64
byDevice := make(map[string]uint64)
for key, buffer := range w.buffers {
total += buffer.bytes
if key.kind == "model" {
onGPU := isGPUBuffer(key.backend)
mmapBacked := strings.HasSuffix(key.backend, "_Mapped")
// Device copies and mmap views mirror the on-disk weights, so their
// spans can overlap and double-count on partial offload. Repacked or
// host-pinned CPU copies (e.g. CPU_REPACK) are separate real
// allocations that never overlap the file, so keep them out of the base.
if onGPU || mmapBacked {
modelFileBacked += buffer.bytes
}
if !onGPU && mmapBacked {
cpuMappedModel += buffer.bytes
}
}
if isGPUBuffer(key.backend) {
gpu += buffer.bytes
byDevice[deviceName(key.backend)] += buffer.bytes
}
}
w.runner.memTotal = total
w.runner.memGPU = gpu
w.runner.memModelFileBacked = modelFileBacked
w.runner.memCPUMappedModel = cpuMappedModel
w.runner.vramByDevice = byDevice
}
// VRAMByGPU returns the VRAM used by this runner on the specified device.
// The values are parsed from llama-server's buffer size log output during model load
// (model tensors + KV cache + compute buffers).
func (s *llamaServerRunner) VRAMByGPU(id ml.DeviceID) uint64 {
s.memoryMu.RLock()
defer s.memoryMu.RUnlock()
// Map DeviceID to the log device name used by llama-server.
// Discovery stores the device name (e.g., "CUDA0", "ROCm0", "MTL0") from
// --list-devices stdout, which matches the buffer log prefix.
for _, gpu := range s.gpus {
if gpu.DeviceID == id {
return s.vramByDevice[gpu.Name]
}
}
return 0
}