Skip to content

Commit 00b738c

Browse files
authored
Add Macro-In-Macro Support (#337)
Add full macro-in-macro support so any user defined macro can contain another one as long as it was previously declared in the configuration file. Fixes #336 Supercedes #335
1 parent 70930e4 commit 00b738c

File tree

7 files changed

+717
-70
lines changed

7 files changed

+717
-70
lines changed
Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
# Improve macro-in-macro support
2+
3+
**Status: COMPLETED ✅**
4+
5+
## Title
6+
7+
Fix macro substitution ordering by preserving definition order using ordered YAML parsing
8+
9+
## Overview
10+
11+
The current macro implementation uses `map[string]any` which does not preserve insertion order. This causes issues when macros reference other macros - if macro `B` contains `${A}` but `B` is processed before `A`, the reference won't be substituted, leading to "unknown macro" errors.
12+
13+
**Goal:** Ensure macros are substituted in definition order (LIFO - last in, first out) to allow macros to reliably reference previously-defined macros.
14+
15+
**Outcomes:**
16+
- Macros can reference other macros defined earlier in the config
17+
- Macro substitution is deterministic and order-dependent
18+
- Single-pass substitution prevents circular dependencies
19+
- Use `yaml.Node` from `gopkg.in/yaml.v3` to preserve macro definition order
20+
- All existing tests pass
21+
- New tests validate substitution order and self-reference detection
22+
23+
## Design Requirements
24+
25+
### 1. YAML Parsing Strategy
26+
- **Continue using:** `gopkg.in/yaml.v3` (current library)
27+
- **Use:** `yaml.Node` for ordered parsing of macros
28+
- **Reason:** `yaml.Node` preserves document structure and order, avoiding need for migration
29+
30+
### 2. Data Structure Changes
31+
32+
#### Current Implementation (config.go:19)
33+
```go
34+
type MacroList map[string]any
35+
```
36+
37+
#### New Implementation
38+
```go
39+
type MacroList []MacroEntry
40+
41+
type MacroEntry struct {
42+
Name string
43+
Value any
44+
}
45+
```
46+
47+
**Implementation Note:** Parse macros using `yaml.Node` to extract key-value pairs in document order, then construct the ordered `MacroList`.
48+
49+
### 3. Macro Substitution Order Rules
50+
51+
The substitution must follow this hierarchy (from most specific to least):
52+
53+
1. **Reserved macros** (last): `PORT`, `MODEL_ID` - substituted last, highest priority
54+
2. **Model-level macros** (middle): Defined in specific model config, overrides global
55+
3. **Global macros** (first): Defined at config root level
56+
57+
Within each level, macros are substituted in **reverse definition order** (LIFO):
58+
- The last macro defined is substituted first
59+
- This allows later macros to reference earlier ones
60+
- Single-pass substitution prevents circular dependencies
61+
62+
### 4. Macro Reference Rules
63+
64+
**Allowed:**
65+
- Macro can reference any macro defined **before** it (earlier in the file)
66+
- Model macros can reference global macros
67+
- Macros can reference reserved macros (`${PORT}`, `${MODEL_ID}`)
68+
69+
**Prohibited:**
70+
- Macro cannot reference itself (e.g., `foo: "value ${foo}"`)
71+
- Macro cannot reference macros defined **after** it
72+
- No circular references (prevented by single-pass, ordered substitution)
73+
74+
### 5. Validation Requirements
75+
76+
Add validation to detect:
77+
- **Self-references:** Macro value contains reference to its own name
78+
- **Unknown macros:** After substitution, any remaining `${...}` references
79+
80+
Error messages should be clear:
81+
```
82+
macro 'foo' contains self-reference
83+
unknown macro '${bar}' in model.cmd
84+
```
85+
86+
### 6. Implementation Changes
87+
88+
#### Files to Modify
89+
90+
1. **[proxy/config/config.go](proxy/config/config.go)**
91+
- Line 19: Change `MacroList` type definition
92+
- Line 69: Update `Macros MacroList` field
93+
- Line 153-157: Update macro validation loop to work with ordered structure
94+
- Line 175-188: Update model-level macro validation
95+
- Line 181-188: **NEW** Implement proper macro merging respecting order
96+
- Line 193-202: **NEW** Implement ordered macro substitution in LIFO order
97+
- Line 389-415: Update `validateMacro` to detect self-references
98+
- Line 420-475: Update `substituteMetadataMacros` to accept ordered MacroList
99+
100+
2. **[proxy/config/model_config.go](proxy/config/model_config.go)**
101+
- Line 33: Update `Macros MacroList` field type
102+
103+
3. **All test files**
104+
- Update test fixtures to use ordered macro definitions
105+
- Ensure tests specify macro order explicitly
106+
107+
#### Core Algorithm
108+
109+
Replace the macro substitution logic in [config.go:181-252](proxy/config/config.go#L181-L252) with:
110+
111+
```go
112+
// Merge global config and model macros. Model macros take precedence
113+
mergedMacros := make(MacroList, 0, len(config.Macros)+len(modelConfig.Macros)+2)
114+
115+
// Add global macros first
116+
for _, entry := range config.Macros {
117+
mergedMacros = append(mergedMacros, entry)
118+
}
119+
120+
// Add model macros (can override global)
121+
for _, entry := range modelConfig.Macros {
122+
// Remove any existing global macro with same name
123+
found := false
124+
for i, existing := range mergedMacros {
125+
if existing.Name == entry.Name {
126+
mergedMacros[i] = entry // Override
127+
found = true
128+
break
129+
}
130+
}
131+
if !found {
132+
mergedMacros = append(mergedMacros, entry)
133+
}
134+
}
135+
136+
// Add reserved MODEL_ID macro at the end
137+
mergedMacros = append(mergedMacros, MacroEntry{Name: "MODEL_ID", Value: modelId})
138+
139+
// Check if PORT macro is needed
140+
if strings.Contains(modelConfig.Cmd, "${PORT}") || strings.Contains(modelConfig.Proxy, "${PORT}") || strings.Contains(modelConfig.CmdStop, "${PORT}") {
141+
// enforce ${PORT} used in both cmd and proxy
142+
if !strings.Contains(modelConfig.Cmd, "${PORT}") && strings.Contains(modelConfig.Proxy, "${PORT}") {
143+
return Config{}, fmt.Errorf("model %s: proxy uses ${PORT} but cmd does not - ${PORT} is only available when used in cmd", modelId)
144+
}
145+
146+
// Add PORT macro to the end (highest priority)
147+
mergedMacros = append(mergedMacros, MacroEntry{Name: "PORT", Value: nextPort})
148+
nextPort++
149+
}
150+
151+
// Single-pass substitution: Substitute all macros in LIFO order (last defined first)
152+
// This allows later macros to reference earlier ones
153+
for i := len(mergedMacros) - 1; i >= 0; i-- {
154+
entry := mergedMacros[i]
155+
macroSlug := fmt.Sprintf("${%s}", entry.Name)
156+
macroStr := fmt.Sprintf("%v", entry.Value)
157+
158+
// Substitute in command fields
159+
modelConfig.Cmd = strings.ReplaceAll(modelConfig.Cmd, macroSlug, macroStr)
160+
modelConfig.CmdStop = strings.ReplaceAll(modelConfig.CmdStop, macroSlug, macroStr)
161+
modelConfig.Proxy = strings.ReplaceAll(modelConfig.Proxy, macroSlug, macroStr)
162+
modelConfig.CheckEndpoint = strings.ReplaceAll(modelConfig.CheckEndpoint, macroSlug, macroStr)
163+
modelConfig.Filters.StripParams = strings.ReplaceAll(modelConfig.Filters.StripParams, macroSlug, macroStr)
164+
165+
// Substitute in metadata (recursive)
166+
if len(modelConfig.Metadata) > 0 {
167+
var err error
168+
modelConfig.Metadata, err = substituteMacroInValue(modelConfig.Metadata, entry.Name, entry.Value)
169+
if err != nil {
170+
return Config{}, fmt.Errorf("model %s metadata: %s", modelId, err.Error())
171+
}
172+
}
173+
}
174+
```
175+
176+
Add this new helper function to replace `substituteMetadataMacros`:
177+
178+
```go
179+
// substituteMacroInValue recursively substitutes a single macro in a value structure
180+
// This is called once per macro, allowing LIFO substitution order
181+
func substituteMacroInValue(value any, macroName string, macroValue any) (any, error) {
182+
macroSlug := fmt.Sprintf("${%s}", macroName)
183+
macroStr := fmt.Sprintf("%v", macroValue)
184+
185+
switch v := value.(type) {
186+
case string:
187+
// Check if this is a direct macro substitution
188+
if v == macroSlug {
189+
return macroValue, nil
190+
}
191+
// Handle string interpolation
192+
if strings.Contains(v, macroSlug) {
193+
return strings.ReplaceAll(v, macroSlug, macroStr), nil
194+
}
195+
return v, nil
196+
197+
case map[string]any:
198+
// Recursively process map values
199+
newMap := make(map[string]any)
200+
for key, val := range v {
201+
newVal, err := substituteMacroInValue(val, macroName, macroValue)
202+
if err != nil {
203+
return nil, err
204+
}
205+
newMap[key] = newVal
206+
}
207+
return newMap, nil
208+
209+
case []any:
210+
// Recursively process slice elements
211+
newSlice := make([]any, len(v))
212+
for i, val := range v {
213+
newVal, err := substituteMacroInValue(val, macroName, macroValue)
214+
if err != nil {
215+
return nil, err
216+
}
217+
newSlice[i] = newVal
218+
}
219+
return newSlice, nil
220+
221+
default:
222+
// Return scalar types as-is
223+
return value, nil
224+
}
225+
}
226+
```
227+
228+
### 7. Self-Reference Detection
229+
230+
Add to `validateMacro` function:
231+
232+
```go
233+
func validateMacro(name string, value any) error {
234+
// ... existing validation ...
235+
236+
// Check for self-reference
237+
if str, ok := value.(string); ok {
238+
macroSlug := fmt.Sprintf("${%s}", name)
239+
if strings.Contains(str, macroSlug) {
240+
return fmt.Errorf("macro '%s' contains self-reference", name)
241+
}
242+
}
243+
244+
return nil
245+
}
246+
```
247+
248+
## Testing Plan
249+
250+
### 1. Migration Tests
251+
- **Test:** All existing macro tests still pass after YAML library migration
252+
- **Files:** All `*_test.go` files with macro tests
253+
254+
### 2. Macro Order Tests
255+
256+
#### Test: Macro-in-macro substitution order
257+
```yaml
258+
macros:
259+
"A": "value-A"
260+
"B": "prefix-${A}-suffix"
261+
262+
models:
263+
test:
264+
cmd: "echo ${B}"
265+
```
266+
**Expected:** `cmd` becomes `"echo prefix-value-A-suffix"`
267+
268+
#### Test: LIFO substitution order
269+
```yaml
270+
macros:
271+
"base": "/models"
272+
"path": "${base}/llama"
273+
"full": "${path}/model.gguf"
274+
275+
models:
276+
test:
277+
cmd: "load ${full}"
278+
```
279+
**Expected:** `cmd` becomes `"load /models/llama/model.gguf"`
280+
281+
#### Test: Model macro overrides global
282+
```yaml
283+
macros:
284+
"tag": "global"
285+
"msg": "value-${tag}"
286+
287+
models:
288+
test:
289+
macros:
290+
"tag": "model-level"
291+
cmd: "echo ${msg}"
292+
```
293+
**Expected:** `cmd` becomes `"echo value-model-level"` (model macro overrides global)
294+
295+
### 3. Reserved Macro Tests
296+
297+
#### Test: MODEL_ID substituted in macro
298+
```yaml
299+
macros:
300+
"podman-llama": "podman run --name ${MODEL_ID} ghcr.io/ggml-org/llama.cpp:server-cuda"
301+
302+
models:
303+
my-model:
304+
cmd: "${podman-llama} -m model.gguf"
305+
```
306+
**Expected:** `cmd` becomes `"podman run --name my-model ghcr.io/ggml-org/llama.cpp:server-cuda -m model.gguf"`
307+
308+
### 4. Error Detection Tests
309+
310+
#### Test: Self-reference detection
311+
```yaml
312+
macros:
313+
"recursive": "value-${recursive}"
314+
```
315+
**Expected:** Error: `macro 'recursive' contains self-reference`
316+
317+
#### Test: Undefined macro reference
318+
```yaml
319+
macros:
320+
"A": "value-${UNDEFINED}"
321+
```
322+
**Expected:** Error: `unknown macro '${UNDEFINED}' found in macros.A` (or similar)
323+
324+
### 5. Regression Tests
325+
- Run all existing macro tests: `TestConfig_MacroReplacement`, `TestConfig_MacroReservedNames`, etc.
326+
- Ensure all pass without modification (except test fixtures if needed)
327+
328+
## Checklist
329+
330+
### Phase 1: Data Structure Changes
331+
- [ ] Implement custom `UnmarshalYAML` method for `MacroList` that uses `yaml.Node`
332+
- [ ] Define new ordered `MacroList` type as `[]MacroEntry`
333+
- [ ] Update `MacroList` type definition in [config.go](proxy/config/config.go#L19)
334+
- [ ] Update `Config.Macros` field type in [config.go](proxy/config/config.go#L69)
335+
- [ ] Update `ModelConfig.Macros` field type in [model_config.go](proxy/config/model_config.go#L33)
336+
- [ ] Implement helper functions:
337+
- [ ] `func (ml MacroList) Get(name string) (any, bool)` - lookup by name
338+
- [ ] `func (ml MacroList) Set(name string, value any) MacroList` - add/override entry
339+
- [ ] `func (ml MacroList) ToMap() map[string]any` - convert to map if needed
340+
341+
### Phase 2: Macro Validation Updates
342+
- [ ] Update macro validation loop at [config.go:153-157](proxy/config/config.go#L153-L157)
343+
- [ ] Update model macro validation at [config.go:175-179](proxy/config/config.go#L175-L179)
344+
- [ ] Add self-reference detection to `validateMacro` function [config.go:389](proxy/config/config.go#L389)
345+
- [ ] Test self-reference detection with new test case
346+
347+
### Phase 3: Macro Substitution Algorithm
348+
- [ ] Implement ordered macro merging (global → model → reserved) at [config.go:181-188](proxy/config/config.go#L181-L188)
349+
- [ ] Implement single-pass LIFO substitution loop (reverse iteration) at [config.go:193-202](proxy/config/config.go#L193-L202)
350+
- [ ] Substitute in all string fields (cmd, cmdStop, proxy, checkEndpoint, stripParams)
351+
- [ ] Substitute in metadata within same loop
352+
- [ ] Ensure `MODEL_ID` is added to merged macros before substitution
353+
- [ ] Ensure `PORT` is added after port assignment (if needed)
354+
- [ ] Replace `substituteMetadataMacros` with new `substituteMacroInValue` function that processes one macro at a time [config.go:420](proxy/config/config.go#L420)
355+
- [ ] Remove old metadata substitution code that was separate from main loop [config.go:245-251](proxy/config/config.go#L245-L251)
356+
357+
### Phase 4: Testing
358+
- [ ] Run `make test-dev` - fix any static checking errors
359+
- [ ] Add test: macro-in-macro basic substitution
360+
- [ ] Add test: LIFO substitution order with 3+ macro levels
361+
- [ ] Add test: MODEL_ID in global macro used by model
362+
- [ ] Add test: PORT in global macro used by model
363+
- [ ] Add test: model macro overrides global macro in substitution
364+
- [ ] Add test: self-reference detection error
365+
- [ ] Add test: undefined macro reference error
366+
- [ ] Verify all existing macro tests pass: `TestConfig_Macro*`
367+
- [ ] Run `make test-all` - ensure all tests including concurrency tests pass
368+
369+
### Phase 5: Documentation
370+
- [ ] Update plan status in this file (mark completed)
371+
- [ ] Update CLAUDE.md if macro behavior needs documentation
372+
- [ ] Verify no new error messages need user documentation
373+
374+
## Bug Example (Original Issue)
375+
376+
```yaml
377+
macros:
378+
"podman-llama": >
379+
podman run --name ${MODEL_ID}
380+
--init --rm -p ${PORT}:8080 -v /home/alex/ai/models:/models:z --gpus=all
381+
ghcr.io/ggml-org/llama.cpp:server-cuda
382+
383+
"standard-options": >
384+
--no-mmap --jinja
385+
386+
"kv8": >
387+
-fa on -ctk q8_0 -ctv q8_0
388+
```
389+
390+
**Current Bug:**
391+
- During macro substitution, if `${MODEL_ID}` is processed before `${podman-llama}`, the `${MODEL_ID}` reference inside `podman-llama` remains unsubstituted
392+
- Results in error: `unknown macro '${MODEL_ID}' found in model.cmd`
393+
394+
**After Fix:**
395+
- Macros substituted in LIFO order: `kv8` → `standard-options` → `podman-llama`
396+
- `MODEL_ID` is a reserved macro, substituted last (after all user macros)
397+
- `${MODEL_ID}` inside `podman-llama` is correctly replaced with the model name

0 commit comments

Comments
 (0)