Skip to content

Commit bde387a

Browse files
committed
feat(api): Allow coding agents to interactively discover how to control and configure LocalAI
Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 73bdc3b commit bde387a

14 files changed

Lines changed: 2036 additions & 8 deletions

File tree

core/config/meta/build.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package meta
2+
3+
import (
4+
"reflect"
5+
"sort"
6+
"sync"
7+
)
8+
9+
var (
10+
cachedMetadata *ConfigMetadata
11+
cacheMu sync.RWMutex
12+
)
13+
14+
// BuildConfigMetadata reflects on the given struct type (ModelConfig),
15+
// merges the enrichment registry, and returns the full ConfigMetadata.
16+
// The result is cached in memory after the first call.
17+
func BuildConfigMetadata(modelConfigType reflect.Type) *ConfigMetadata {
18+
cacheMu.RLock()
19+
if cachedMetadata != nil {
20+
cacheMu.RUnlock()
21+
return cachedMetadata
22+
}
23+
cacheMu.RUnlock()
24+
25+
cacheMu.Lock()
26+
defer cacheMu.Unlock()
27+
28+
// Double-check after acquiring write lock
29+
if cachedMetadata != nil {
30+
return cachedMetadata
31+
}
32+
33+
cachedMetadata = buildConfigMetadataUncached(modelConfigType, DefaultRegistry())
34+
return cachedMetadata
35+
}
36+
37+
// buildConfigMetadataUncached does the actual work without caching.
38+
// Exported via lowercase for testability through BuildForTest.
39+
func buildConfigMetadataUncached(modelConfigType reflect.Type, registry map[string]FieldMetaOverride) *ConfigMetadata {
40+
fields := WalkModelConfig(modelConfigType)
41+
42+
// Apply registry overrides
43+
for i := range fields {
44+
override, ok := registry[fields[i].Path]
45+
if !ok {
46+
continue
47+
}
48+
applyOverride(&fields[i], override)
49+
}
50+
51+
// Sort fields by section order then by field order
52+
sectionOrder := make(map[string]int)
53+
for _, s := range DefaultSections() {
54+
sectionOrder[s.ID] = s.Order
55+
}
56+
57+
sort.SliceStable(fields, func(i, j int) bool {
58+
si := sectionOrder[fields[i].Section]
59+
sj := sectionOrder[fields[j].Section]
60+
if si != sj {
61+
return si < sj
62+
}
63+
return fields[i].Order < fields[j].Order
64+
})
65+
66+
// Collect sections that actually have fields
67+
usedSections := make(map[string]bool)
68+
for _, f := range fields {
69+
usedSections[f.Section] = true
70+
}
71+
72+
var sections []Section
73+
for _, s := range DefaultSections() {
74+
if usedSections[s.ID] {
75+
sections = append(sections, s)
76+
}
77+
}
78+
79+
return &ConfigMetadata{
80+
Sections: sections,
81+
Fields: fields,
82+
}
83+
}
84+
85+
// applyOverride merges non-zero override values into the field.
86+
func applyOverride(f *FieldMeta, o FieldMetaOverride) {
87+
if o.Section != "" {
88+
f.Section = o.Section
89+
}
90+
if o.Label != "" {
91+
f.Label = o.Label
92+
}
93+
if o.Description != "" {
94+
f.Description = o.Description
95+
}
96+
if o.Component != "" {
97+
f.Component = o.Component
98+
}
99+
if o.Placeholder != "" {
100+
f.Placeholder = o.Placeholder
101+
}
102+
if o.Default != nil {
103+
f.Default = o.Default
104+
}
105+
if o.Min != nil {
106+
f.Min = o.Min
107+
}
108+
if o.Max != nil {
109+
f.Max = o.Max
110+
}
111+
if o.Step != nil {
112+
f.Step = o.Step
113+
}
114+
if o.Options != nil {
115+
f.Options = o.Options
116+
}
117+
if o.AutocompleteProvider != "" {
118+
f.AutocompleteProvider = o.AutocompleteProvider
119+
}
120+
if o.VRAMImpact {
121+
f.VRAMImpact = true
122+
}
123+
if o.Advanced {
124+
f.Advanced = true
125+
}
126+
if o.Order != 0 {
127+
f.Order = o.Order
128+
}
129+
}
130+
131+
// BuildForTest builds metadata without caching, for use in tests.
132+
func BuildForTest(modelConfigType reflect.Type, registry map[string]FieldMetaOverride) *ConfigMetadata {
133+
return buildConfigMetadataUncached(modelConfigType, registry)
134+
}
135+
136+
// ResetCache clears the cached metadata (useful for testing).
137+
func ResetCache() {
138+
cacheMu.Lock()
139+
defer cacheMu.Unlock()
140+
cachedMetadata = nil
141+
}

core/config/meta/build_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package meta_test
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
"github.com/mudler/LocalAI/core/config/meta"
9+
)
10+
11+
func TestBuildConfigMetadata(t *testing.T) {
12+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
13+
14+
if len(md.Sections) == 0 {
15+
t.Fatal("expected sections, got 0")
16+
}
17+
if len(md.Fields) == 0 {
18+
t.Fatal("expected fields, got 0")
19+
}
20+
21+
// Verify sections are ordered
22+
for i := 1; i < len(md.Sections); i++ {
23+
if md.Sections[i].Order < md.Sections[i-1].Order {
24+
t.Errorf("sections not ordered: %s (order=%d) before %s (order=%d)",
25+
md.Sections[i-1].ID, md.Sections[i-1].Order,
26+
md.Sections[i].ID, md.Sections[i].Order)
27+
}
28+
}
29+
}
30+
31+
func TestRegistryOverrides(t *testing.T) {
32+
registry := map[string]meta.FieldMetaOverride{
33+
"name": {
34+
Label: "My Custom Label",
35+
Description: "Custom description",
36+
Component: "textarea",
37+
Order: 999,
38+
},
39+
}
40+
41+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), registry)
42+
43+
byPath := make(map[string]meta.FieldMeta, len(md.Fields))
44+
for _, f := range md.Fields {
45+
byPath[f.Path] = f
46+
}
47+
48+
f, ok := byPath["name"]
49+
if !ok {
50+
t.Fatal("field 'name' not found")
51+
}
52+
if f.Label != "My Custom Label" {
53+
t.Errorf("expected label 'My Custom Label', got %q", f.Label)
54+
}
55+
if f.Description != "Custom description" {
56+
t.Errorf("expected description 'Custom description', got %q", f.Description)
57+
}
58+
if f.Component != "textarea" {
59+
t.Errorf("expected component 'textarea', got %q", f.Component)
60+
}
61+
if f.Order != 999 {
62+
t.Errorf("expected order 999, got %d", f.Order)
63+
}
64+
}
65+
66+
func TestUnregisteredFieldsGetDefaults(t *testing.T) {
67+
// Use empty registry - all fields should still get auto-generated metadata
68+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), map[string]meta.FieldMetaOverride{})
69+
70+
byPath := make(map[string]meta.FieldMeta, len(md.Fields))
71+
for _, f := range md.Fields {
72+
byPath[f.Path] = f
73+
}
74+
75+
// context_size should still exist with auto-generated label
76+
f, ok := byPath["context_size"]
77+
if !ok {
78+
t.Fatal("field 'context_size' not found")
79+
}
80+
if f.Label == "" {
81+
t.Error("expected auto-generated label, got empty")
82+
}
83+
if f.UIType != "int" {
84+
t.Errorf("expected UIType 'int', got %q", f.UIType)
85+
}
86+
if f.Component == "" {
87+
t.Error("expected auto-generated component, got empty")
88+
}
89+
}
90+
91+
func TestDefaultRegistryOverridesApply(t *testing.T) {
92+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
93+
94+
byPath := make(map[string]meta.FieldMeta, len(md.Fields))
95+
for _, f := range md.Fields {
96+
byPath[f.Path] = f
97+
}
98+
99+
// Verify enriched fields got their overrides
100+
tests := []struct {
101+
path string
102+
label string
103+
description string
104+
vramImpact bool
105+
}{
106+
{"context_size", "Context Size", "Maximum context window in tokens", true},
107+
{"gpu_layers", "GPU Layers", "Number of layers to offload to GPU (-1 = all)", true},
108+
{"backend", "Backend", "The inference backend to use (e.g. llama-cpp, vllm, diffusers)", false},
109+
{"parameters.temperature", "Temperature", "Sampling temperature (higher = more creative, lower = more deterministic)", false},
110+
{"template.chat", "Chat Template", "Go template for chat completion requests", false},
111+
}
112+
113+
for _, tt := range tests {
114+
f, ok := byPath[tt.path]
115+
if !ok {
116+
t.Errorf("field %q not found", tt.path)
117+
continue
118+
}
119+
if f.Label != tt.label {
120+
t.Errorf("field %q: expected label %q, got %q", tt.path, tt.label, f.Label)
121+
}
122+
if f.Description != tt.description {
123+
t.Errorf("field %q: expected description %q, got %q", tt.path, tt.description, f.Description)
124+
}
125+
if f.VRAMImpact != tt.vramImpact {
126+
t.Errorf("field %q: expected vramImpact=%v, got %v", tt.path, tt.vramImpact, f.VRAMImpact)
127+
}
128+
}
129+
}
130+
131+
func TestStaticOptionsFields(t *testing.T) {
132+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
133+
134+
byPath := make(map[string]meta.FieldMeta, len(md.Fields))
135+
for _, f := range md.Fields {
136+
byPath[f.Path] = f
137+
}
138+
139+
// Fields with static options should have Options populated and no AutocompleteProvider
140+
staticFields := []string{"quantization", "cache_type_k", "cache_type_v", "diffusers.pipeline_type", "diffusers.scheduler_type"}
141+
for _, path := range staticFields {
142+
f, ok := byPath[path]
143+
if !ok {
144+
t.Errorf("field %q not found", path)
145+
continue
146+
}
147+
if len(f.Options) == 0 {
148+
t.Errorf("field %q: expected Options to be populated", path)
149+
}
150+
if f.AutocompleteProvider != "" {
151+
t.Errorf("field %q: expected no AutocompleteProvider, got %q", path, f.AutocompleteProvider)
152+
}
153+
}
154+
}
155+
156+
func TestDynamicProviderFields(t *testing.T) {
157+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
158+
159+
byPath := make(map[string]meta.FieldMeta, len(md.Fields))
160+
for _, f := range md.Fields {
161+
byPath[f.Path] = f
162+
}
163+
164+
// Fields with dynamic providers should have AutocompleteProvider and no Options
165+
dynamicFields := map[string]string{
166+
"backend": meta.ProviderBackends,
167+
"pipeline.llm": meta.ProviderModelsChat,
168+
"pipeline.tts": meta.ProviderModelsTTS,
169+
"pipeline.transcription": meta.ProviderModelsTranscript,
170+
"pipeline.vad": meta.ProviderModelsVAD,
171+
}
172+
for path, expectedProvider := range dynamicFields {
173+
f, ok := byPath[path]
174+
if !ok {
175+
t.Errorf("field %q not found", path)
176+
continue
177+
}
178+
if f.AutocompleteProvider != expectedProvider {
179+
t.Errorf("field %q: expected AutocompleteProvider %q, got %q", path, expectedProvider, f.AutocompleteProvider)
180+
}
181+
if len(f.Options) != 0 {
182+
t.Errorf("field %q: expected no Options, got %d", path, len(f.Options))
183+
}
184+
}
185+
}
186+
187+
func TestVRAMImpactFields(t *testing.T) {
188+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
189+
190+
var vramFields []string
191+
for _, f := range md.Fields {
192+
if f.VRAMImpact {
193+
vramFields = append(vramFields, f.Path)
194+
}
195+
}
196+
197+
if len(vramFields) == 0 {
198+
t.Error("expected some VRAM impact fields, got 0")
199+
}
200+
201+
// context_size and gpu_layers should be marked
202+
expected := map[string]bool{"context_size": true, "gpu_layers": true}
203+
for _, path := range vramFields {
204+
if expected[path] {
205+
delete(expected, path)
206+
}
207+
}
208+
for path := range expected {
209+
t.Errorf("expected VRAM impact field %q not found", path)
210+
}
211+
}

0 commit comments

Comments
 (0)