Skip to content

Commit e5a3116

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 8da7212 commit e5a3116

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+7593
-329
lines changed

core/config/meta/build.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
if cachedMetadata != nil {
29+
return cachedMetadata
30+
}
31+
32+
cachedMetadata = buildConfigMetadataUncached(modelConfigType, DefaultRegistry())
33+
return cachedMetadata
34+
}
35+
36+
// buildConfigMetadataUncached does the actual work without caching.
37+
func buildConfigMetadataUncached(modelConfigType reflect.Type, registry map[string]FieldMetaOverride) *ConfigMetadata {
38+
fields := WalkModelConfig(modelConfigType)
39+
40+
for i := range fields {
41+
override, ok := registry[fields[i].Path]
42+
if !ok {
43+
continue
44+
}
45+
applyOverride(&fields[i], override)
46+
}
47+
48+
allSections := DefaultSections()
49+
50+
sectionOrder := make(map[string]int, len(allSections))
51+
for _, s := range allSections {
52+
sectionOrder[s.ID] = s.Order
53+
}
54+
55+
sort.SliceStable(fields, func(i, j int) bool {
56+
si := sectionOrder[fields[i].Section]
57+
sj := sectionOrder[fields[j].Section]
58+
if si != sj {
59+
return si < sj
60+
}
61+
return fields[i].Order < fields[j].Order
62+
})
63+
64+
usedSections := make(map[string]bool)
65+
for _, f := range fields {
66+
usedSections[f.Section] = true
67+
}
68+
69+
var sections []Section
70+
for _, s := range allSections {
71+
if usedSections[s.ID] {
72+
sections = append(sections, s)
73+
}
74+
}
75+
76+
return &ConfigMetadata{
77+
Sections: sections,
78+
Fields: fields,
79+
}
80+
}
81+
82+
// applyOverride merges non-zero override values into the field.
83+
func applyOverride(f *FieldMeta, o FieldMetaOverride) {
84+
if o.Section != "" {
85+
f.Section = o.Section
86+
}
87+
if o.Label != "" {
88+
f.Label = o.Label
89+
}
90+
if o.Description != "" {
91+
f.Description = o.Description
92+
}
93+
if o.Component != "" {
94+
f.Component = o.Component
95+
}
96+
if o.Placeholder != "" {
97+
f.Placeholder = o.Placeholder
98+
}
99+
if o.Default != nil {
100+
f.Default = o.Default
101+
}
102+
if o.Min != nil {
103+
f.Min = o.Min
104+
}
105+
if o.Max != nil {
106+
f.Max = o.Max
107+
}
108+
if o.Step != nil {
109+
f.Step = o.Step
110+
}
111+
if o.Options != nil {
112+
f.Options = o.Options
113+
}
114+
if o.AutocompleteProvider != "" {
115+
f.AutocompleteProvider = o.AutocompleteProvider
116+
}
117+
if o.VRAMImpact {
118+
f.VRAMImpact = true
119+
}
120+
if o.Advanced {
121+
f.Advanced = true
122+
}
123+
if o.Order != 0 {
124+
f.Order = o.Order
125+
}
126+
}
127+
128+
// BuildForTest builds metadata without caching, for use in tests.
129+
func BuildForTest(modelConfigType reflect.Type, registry map[string]FieldMetaOverride) *ConfigMetadata {
130+
return buildConfigMetadataUncached(modelConfigType, registry)
131+
}
132+

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)