package autogen import ( "sort" "strings" "github.com/quartermaster-labs/quartermaster/internal/config" ) // Custom launch arguments are applied to a rendered command in ONE direction: // the user's text is never decomposed. For every knob the text sets, the // generated occurrences of that knob are dropped, and the text is appended // verbatim at the end, where llama-server's own last-flag-wins rule gives it // the final say. // // The alternative (parsing the text back into structured fields, which is what // the old launch-parameters box did) is a lossy round trip: every flag needs a // parser case, every miss lands in the free-form bucket, and flags like -cram // ended up emitted twice, once from the field and once from the text. // Token provenance values for CmdToken.Source. const ( SourceGenerated = "generated" SourceCustom = "custom" ) // CmdToken is one token of a launch command with the provenance the editor // needs to render the composed command without diffing strings. Suppressed // tokens are generated tokens that a custom knob replaced; they are reported so // the editor can strike them through or summarise the count. type CmdToken struct { Text string `json:"text"` Source string `json:"source"` Knob string `json:"knob,omitempty"` Suppressed bool `json:"suppressed,omitempty"` } // ComposedCmd is a rendered command in layers. type ComposedCmd struct { // Generated is the command as the generator emitted it (no custom text). Generated string `json:"generated"` // Effective is what will actually run. Effective string `json:"effective"` // Custom is the user's text, unchanged. Custom string `json:"custom,omitempty"` // Lines is the effective command split back into emittable lines. It is // byte-identical to the input lines when Custom is empty. Lines []string `json:"-"` // Tokens is every token of both layers, in order, with provenance. Tokens []CmdToken `json:"tokens,omitempty"` // Owned lists the knobs the custom text sets, sorted. Owned []string `json:"ownedKnobs,omitempty"` // Issues is what the editor's flag check found in Tokens. Empty for a // command with no custom text, and never consumed by config emission. Issues []CmdIssue `json:"issues,omitempty"` } // CustomArgsText returns the custom launch-argument text in effect: the new // CustomArgs field when it carries anything, else the legacy ExtraArgs bucket // written by the old two-way box. CustomArgsOff suppresses both without // discarding them, so a disable/enable round trip in the editor loses nothing. func (o *Override) CustomArgsText() string { if o == nil || o.CustomArgsOff { return "" } if strings.TrimSpace(o.CustomArgs) != "" { return o.CustomArgs } return o.ExtraArgs } // ClearOwnedFieldsFromText parses the override's effective custom text and // zeroes the structured fields it shadows. This is the save-time half of the // ownership rule: dropping the generated flag at emit time is not enough, since // the stale field would resurface the moment the user deletes the flag from the // text (issue #38's mmap reset). Unparseable text clears nothing. // // It resolves against BOTH flag tables, unlike ComposeCmd, which must be handed // exactly one. The two answer different questions. Suppression decides what a // specific binary is launched with, so guessing the table there would emit the // wrong argv; clearing only zeroes fields on an Override that carries both // backends' knobs and never uses more than one set, so resolving a diffusion // model's -H against the llama table's --hf-repo costs nothing but a blank // field nothing reads. That matters because the save and preview endpoints see // the JSON body before anything has read the gguf header, and making them // arch-detect first would put a header read on every keystroke of the editor's // live preview. func ClearOwnedFieldsFromText(ov *Override) []string { text := ov.CustomArgsText() if strings.TrimSpace(text) == "" { return nil } toks, err := config.SanitizeCommand(text) if err != nil { return nil } owned := LlamaFlags.OwnedKnobs(toks) for k, v := range SdFlags.OwnedKnobs(toks) { if v { owned[k] = true } } return ClearOwnedFields(ov, owned) } // ComposeCmd applies custom launch arguments to generated command lines. An // empty (or comment-only) custom text is the identity: the lines are returned // untouched, so a config saved before this feature existed regenerates // byte-identically. // // Unparseable custom text is returned as an error with the command left // unchanged; the caller decides whether to surface it (the preview does) or to // emit it anyway and let the spawn fail as it would have before. func (ft *FlagTable) ComposeCmd(lines []string, custom string) (ComposedCmd, error) { gen := strings.Join(lines, " ") cc := ComposedCmd{Generated: gen, Effective: gen, Lines: lines, Custom: custom} genToks, _ := config.SanitizeCommand(gen) appendGen := func(tok string, knob string, suppressed bool) { cc.Tokens = append(cc.Tokens, CmdToken{Text: tok, Source: SourceGenerated, Knob: knob, Suppressed: suppressed}) } // Comment-only text is blank: the spawn strips comment lines, so it can // suppress nothing and must not turn into an empty-command error. if strings.TrimSpace(config.StripComments(custom)) == "" { for _, t := range genToks { appendGen(t, ft.TokenKnob(t), false) } return cc, nil } customToks, err := config.SanitizeCommand(custom) if err != nil { // Emit the text unchanged (what the old extraArgs path did) so the // spawn fails with the backend's own message; callers that can show a // dialog (the preview) surface err instead. if len(lines) > 0 { cc.Lines = append(append([]string{}, lines...), splitCustomLines(custom)...) cc.Effective = strings.Join(cc.Lines, " ") } return cc, err } if len(customToks) == 0 { // comment-only text for _, t := range genToks { appendGen(t, ft.TokenKnob(t), false) } return cc, nil } owned := ft.OwnedKnobs(customToks) for k := range owned { cc.Owned = append(cc.Owned, k) } sort.Strings(cc.Owned) out := make([]string, 0, len(lines)+2) for _, line := range lines { if strings.TrimSpace(line) == "" { continue } toks, err := config.SanitizeCommand(line) if err != nil { // A line the spawner could not split either; keep it as written and // let the same failure happen at spawn time. out = append(out, line) for _, t := range toks { appendGen(t, ft.TokenKnob(t), false) } continue } kept := make([]string, 0, len(toks)) dropped := false for i := 0; i < len(toks); i++ { tok := toks[i] if !strings.HasPrefix(tok, "-") { kept = append(kept, tok) appendGen(tok, "", false) continue } name, _, inline := SplitFlagToken(tok) def, known := ft.Lookup(name) if known && def.Knob != "" && owned[def.Knob] { dropped = true appendGen(tok, def.Knob, true) for n := 0; def.Value && !inline && n <= def.ExtraValues && i+1 < len(toks); n++ { i++ appendGen(toks[i], def.Knob, true) } continue } kept = append(kept, tok) appendGen(tok, ft.TokenKnob(tok), false) for n := 0; known && def.Value && !inline && n <= def.ExtraValues && i+1 < len(toks); n++ { i++ kept = append(kept, toks[i]) appendGen(toks[i], "", false) } } if len(kept) == 0 { continue } if dropped { // Only a line that actually lost a token is re-serialised; every // other line keeps the emitter's exact bytes. out = append(out, joinTokens(kept)) } else { out = append(out, line) } } for _, line := range splitCustomLines(custom) { out = append(out, line) } for _, t := range customToks { cc.Tokens = append(cc.Tokens, CmdToken{Text: t, Source: SourceCustom, Knob: ft.TokenKnob(t)}) } if len(out) == 0 { return cc, nil } cc.Lines = out cc.Effective = strings.Join(out, " ") return cc, nil } // ComposeCmd composes against the llama-server table, which is what every // llama-server caller wants and what the tests were written against. func ComposeCmd(lines []string, custom string) (ComposedCmd, error) { return LlamaFlags.ComposeCmd(lines, custom) } // splitCustomLines prepares the user's text for the YAML cmd block: one // indented line per non-empty source line, CR stripped. Comment lines are kept // (the spawn's SanitizeCommand drops them, and so does the UI preview). func splitCustomLines(custom string) []string { raw := strings.Split(strings.ReplaceAll(custom, "\r\n", "\n"), "\n") out := make([]string, 0, len(raw)) for _, l := range raw { l = strings.TrimRight(l, "\r") if strings.TrimSpace(l) == "" { continue } out = append(out, l) } return out } // joinTokens re-serialises a token list for the config file. Only tokens that // need it are quoted; everything else keeps its spelling, so the round trip // through SanitizeCommand at spawn time reproduces the same argv. func joinTokens(toks []string) string { var b strings.Builder for i, t := range toks { if i > 0 { b.WriteByte(' ') } b.WriteString(quoteToken(t)) } return b.String() } func quoteToken(tok string) string { if tok == "" || strings.ContainsAny(tok, " \t\"") { return `"` + strings.ReplaceAll(tok, `"`, "") + `"` } return tok }