internal/autogen/llmlora.go
Rama main · réplica de lectura del repositorio original.
package autogen
import (
"fmt"
"strconv"
"strings"
)
// LoRA adapters for llama-server models.
//
// This is deliberately NOT the shape the sd-server side uses, because the two
// backends disagree about when a LoRA is chosen. sd-server takes
// `--lora-model-dir <DIR>` at launch, lists whatever is in it via
// /sdapi/v1/loras, and each REQUEST names the ones it wants. llama.cpp has no
// directory flag: `--lora FNAME` names one adapter file and binds it into the
// process at spawn, so the set a model runs with is fixed for the life of that
// process. A request can only re-weight what is already loaded (POST
// /lora-adapters, or `"lora":[{"id":N,"scale":F}]` on a completion).
//
// The consequence for the UI: settings.loraDirs["llm"] is a base path and a
// browse root, never a flag. On its own it emits nothing at all. Only
// Override.Loras reaches the backend.
// resolveLlmLoraDir is the folder a model's bare LoRA filenames resolve
// against: settings.loraDirs["llm"], else the fleet-wide settings.loraDir, else
// the directory the model gguf itself sits in.
//
// The last fallback mirrors the image side and buys the same zero-config case:
// an adapter dropped next to the checkpoint it was trained for needs no setting
// at all. There is no per-model override in this ladder on purpose - a model
// that wants an adapter from somewhere else writes an absolute Path, which
// skips the ladder entirely and is clearer than a second directory knob.
func resolveLlmLoraDir(s Settings, modelPath string) string {
if d := strings.TrimSpace(s.LoraDirs["llm"]); d != "" {
return d
}
if d := strings.TrimSpace(s.LoraDir); d != "" {
return d
}
p := strings.ReplaceAll(strings.TrimSpace(modelPath), "\\", "/")
if i := strings.LastIndex(p, "/"); i > 0 {
return p[:i]
}
return ""
}
// isAbsLoraPath reports whether p already names a location on its own, so the
// LoRA folder must not be prepended.
//
// Hand-rolled rather than filepath.IsAbs because a generated config is shared
// across platforms and is routinely written on one and read on another: a Linux
// build of quartermaster reading a config written on Windows must still
// recognise "D:/loras/x.gguf" as absolute, which filepath.IsAbs would not (it
// compiles per-GOOS). Covers drive letters, UNC/POSIX roots, and a bare
// leading slash.
func isAbsLoraPath(p string) bool {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" {
return false
}
if strings.HasPrefix(p, "/") { // POSIX root, and //server/share
return true
}
// "C:/..." or bare "C:" style drive-relative; both are location-bearing.
if len(p) >= 2 && p[1] == ':' {
c := p[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
return false
}
// resolveLoraPath joins a LoRA reference onto dir unless it is already
// absolute. Separator-normalised to "/" the way cmdPath expects.
func resolveLoraPath(dir, p string) string {
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
if p == "" {
return ""
}
if isAbsLoraPath(p) {
return p
}
dir = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(dir), "\\", "/"), "/")
if dir == "" {
return p
}
return dir + "/" + strings.TrimLeft(p, "/")
}
// llmLoraLines renders one launch flag per attached adapter. Empty (not nil
// slice semantics, just no lines) for a model with none, which is every model
// that predates this field, so existing configs emit byte-identically.
//
// A blank Path is skipped rather than erroring: the editor's list can hold a
// half-typed row while the launch-command preview re-renders on every
// keystroke, and a hard failure there would blank the preview mid-edit.
func llmLoraLines(s Settings, ov *Override, modelPath string) []string {
if ov == nil || len(ov.Loras) == 0 {
return nil
}
dir := resolveLlmLoraDir(s, modelPath)
lines := make([]string, 0, len(ov.Loras))
for _, l := range ov.Loras {
full := resolveLoraPath(dir, l.Path)
if full == "" {
continue
}
if l.Scale == nil {
lines = append(lines, "--lora "+cmdPath(full))
continue
}
// %g, matching every other float the emitter writes, so 1 renders as "1"
// rather than "1.000000" and the generated YAML stays diffable.
lines = append(lines, fmt.Sprintf("--lora-scaled %s %s", cmdPath(full), strconv.FormatFloat(*l.Scale, 'g', -1, 64)))
}
return lines
}
// LlmLoraDir is resolveLlmLoraDir for callers outside the package: the config
// editor lists this folder so a model's adapters can be picked rather than
// typed. modelPath may be "" to ask only about the configured folders.
func LlmLoraDir(s Settings, modelPath string) string {
return resolveLlmLoraDir(s, modelPath)
}
// ResolveLoraPath is resolveLoraPath for callers outside the package, so the
// editor can show the absolute path a bare filename will become without
// duplicating the join rules (which are NOT filepath's - see isAbsLoraPath).
func ResolveLoraPath(dir, p string) string {
return resolveLoraPath(dir, p)
}