Ctrl K
ComunidadGuías…

internal/autogen/llmlora_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package autogen

import (
	"strings"
	"testing"
)

func f64(v float64) *float64 { return &v }

func TestResolveLlmLoraDir_ladder(t *testing.T) {
	const model = "D:/LLM/Models/qwen/qwen3-27b-Q5_K_M.gguf"
	cases := []struct {
		name string
		s    Settings
		want string
	}{
		{"llm key wins", Settings{
			LoraDirs: map[string]string{"llm": "D:/loras/llm", "image": "D:/loras/img"},
			LoraDir:  "D:/loras/all",
		}, "D:/loras/llm"},
		{"falls back to fleet-wide", Settings{
			LoraDirs: map[string]string{"image": "D:/loras/img"},
			LoraDir:  "D:/loras/all",
		}, "D:/loras/all"},
		{"falls back to the model's own dir", Settings{}, "D:/LLM/Models/qwen"},
		{"blank llm key is not a setting", Settings{
			LoraDirs: map[string]string{"llm": "   "},
			LoraDir:  "D:/loras/all",
		}, "D:/loras/all"},
		// The image key must never leak into the llama-server path: an SDXL LoRA
		// is not loadable by a text model, and this is the whole reason the map
		// is keyed by category rather than shared.
		{"image key is not consulted", Settings{
			LoraDirs: map[string]string{"image": "D:/loras/img"},
		}, "D:/LLM/Models/qwen"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := resolveLlmLoraDir(c.s, model); got != c.want {
				t.Fatalf("got %q, want %q", got, c.want)
			}
		})
	}
}

func TestResolveLlmLoraDir_backslashModelPath(t *testing.T) {
	// A Windows-written generate file carries backslashes; the fallback has to
	// find the last separator anyway.
	got := resolveLlmLoraDir(Settings{}, `D:\LLM\Models\qwen\q.gguf`)
	if got != "D:/LLM/Models/qwen" {
		t.Fatalf("got %q", got)
	}
}

func TestResolveLoraPath(t *testing.T) {
	cases := []struct{ dir, in, want string }{
		{"D:/loras", "adapter.gguf", "D:/loras/adapter.gguf"},
		{"D:/loras/", "adapter.gguf", "D:/loras/adapter.gguf"},
		{"D:/loras", "sub/adapter.gguf", "D:/loras/sub/adapter.gguf"},
		{"D:/loras", `sub\adapter.gguf`, "D:/loras/sub/adapter.gguf"},
		// Absolute in every spelling a shared config might carry: an absolute
		// entry must survive being read on a platform whose filepath.IsAbs
		// disagrees, which is why isAbsLoraPath is hand-rolled.
		{"D:/loras", "E:/other/adapter.gguf", "E:/other/adapter.gguf"},
		{"D:/loras", `E:\other\adapter.gguf`, "E:/other/adapter.gguf"},
		{"D:/loras", "/mnt/loras/adapter.gguf", "/mnt/loras/adapter.gguf"},
		{"D:/loras", `\\nas\share\adapter.gguf`, "//nas/share/adapter.gguf"},
		// No folder configured and no model dir: the name stands alone rather
		// than becoming "/adapter.gguf".
		{"", "adapter.gguf", "adapter.gguf"},
		{"D:/loras", "  ", ""},
	}
	for _, c := range cases {
		if got := resolveLoraPath(c.dir, c.in); got != c.want {
			t.Errorf("resolveLoraPath(%q, %q) = %q, want %q", c.dir, c.in, got, c.want)
		}
	}
}

func TestLlmLoraLines(t *testing.T) {
	s := Settings{LoraDirs: map[string]string{"llm": "D:/loras/llm"}}
	const model = "D:/LLM/Models/qwen/q.gguf"

	if got := llmLoraLines(s, nil, model); got != nil {
		t.Fatalf("nil override should emit nothing, got %v", got)
	}
	if got := llmLoraLines(s, &Override{}, model); got != nil {
		t.Fatalf("a model with no adapters must emit nothing (existing configs stay byte-identical), got %v", got)
	}

	ov := &Override{Loras: []LoraRef{
		{Path: "a.gguf"},                  // no scale => plain --lora
		{Path: "b.gguf", Scale: f64(0.7)}, // scaled
		{Path: "c.gguf", Scale: f64(0)},   // 0 is REAL: loaded-but-inert, enabled per request
		{Path: "  "},                      // half-typed editor row, skipped
		{Path: "E:/elsewhere/d.gguf", Scale: f64(1)},
	}}
	got := llmLoraLines(s, ov, model)
	want := []string{
		`--lora "D:/loras/llm/a.gguf"`,
		`--lora-scaled "D:/loras/llm/b.gguf" 0.7`,
		`--lora-scaled "D:/loras/llm/c.gguf" 0`,
		`--lora-scaled "E:/elsewhere/d.gguf" 1`,
	}
	if len(got) != len(want) {
		t.Fatalf("got %d lines %v, want %d", len(got), got, len(want))
	}
	for i := range want {
		if got[i] != want[i] {
			t.Errorf("line %d = %q, want %q", i, got[i], want[i])
		}
	}
}

// --lora is Additive: a user's own --lora in custom args must ADD to the
// model's adapters, not silently replace them the way every ReplaceAll flag
// does. Guards both halves: the generated lines survive, and ClearOwnedFields
// does not wipe Override.Loras.
func TestOwnedKnobs_loraIsNotOwned(t *testing.T) {
	owned := OwnedKnobs([]string{"--lora", "D:/x/extra.gguf"})
	if owned["loras"] {
		t.Fatal("--lora must not mark the loras knob owned (it is Additive)")
	}
	// A ReplaceAll neighbour still behaves, so the change is scoped.
	if !OwnedKnobs([]string{"-c", "4096"})["ctx"] {
		t.Fatal("-c should still own ctx")
	}
}

// --lora-scaled takes TWO value tokens. An under-counted flag leaves its scale
// stranded as a bare token, which the walk then treats as a positional.
func TestOwnedKnobs_loraScaledConsumesBothValues(t *testing.T) {
	// Without ExtraValues the walk would stop on "0.5" and then read "-c" as a
	// fresh flag anyway, so assert via a token the walk must NOT misread: put a
	// value that looks like a flag in the scale slot's wake.
	owned := OwnedKnobs([]string{"--lora-scaled", "D:/x/a.gguf", "-c", "--top-k", "20"})
	if owned["ctx"] {
		t.Fatal("-c sat in --lora-scaled's second value slot and must have been consumed, not read as a flag")
	}
	if !owned["topK"] {
		t.Fatal("--top-k after the consumed pair should still be seen")
	}
}

func TestFlagTable_loraSpellings(t *testing.T) {
	for _, name := range []string{"--lora", "--lora-scaled"} {
		d, ok := LookupFlag(name)
		if !ok {
			t.Fatalf("%s missing from the flag table", name)
		}
		if d.Repeat != Additive {
			t.Errorf("%s must be Additive", name)
		}
		if d.Knob != "loras" {
			t.Errorf("%s knob = %q, want loras", name, d.Knob)
		}
	}
	if d, _ := LookupFlag("--lora-scaled"); d.ExtraValues != 1 {
		t.Errorf("--lora-scaled takes FNAME SCALE, so ExtraValues must be 1, got %d", d.ExtraValues)
	}
	if d, _ := LookupFlag("--lora"); d.ExtraValues != 0 {
		t.Errorf("--lora takes one value, got ExtraValues %d", d.ExtraValues)
	}
}

// The emitted flags must be reachable from a real command render, not just from
// the helper: the wiring into buildCmdLines is the part that silently goes
// missing.
func TestBuildCmdLines_emitsLoras(t *testing.T) {
	s := Settings{LoraDirs: map[string]string{"llm": "D:/loras/llm"}}
	row := GgufRow{FullPath: "D:/LLM/Models/qwen/q.gguf"}
	ov := &Override{Loras: []LoraRef{{Path: "a.gguf", Scale: f64(0.5)}}}
	lines := buildCmdLines(s, Metadata{}, row, profile{}, 4096, 99, 0, "q8_0", "q8_0", false, ov)
	joined := strings.Join(lines, "\n")
	if !strings.Contains(joined, `--lora-scaled "D:/loras/llm/a.gguf" 0.5`) {
		t.Fatalf("adapter flag missing from the rendered command:\n%s", joined)
	}
	// And a model without adapters must be unchanged.
	if strings.Contains(strings.Join(buildCmdLines(s, Metadata{}, row, profile{}, 4096, 99, 0, "q8_0", "q8_0", false, &Override{}), "\n"), "--lora") {
		t.Fatal("a model with no adapters emitted a --lora flag")
	}
}