|
| 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