cookbook-add-model

Add a new model to the SGLang Cookbook (docs/, Mintlify), config-driven format — instantiate the model-agnostic template into a per-model config (+ benchmarks) JSX under src/snippets/configs/, an MDX page, the docs.json nav entry, NEW-tag hygiene, and the homepage vendor card. Interactive, multi-phase. Run with /cookbook-add-model.

Install
npx skills add 'https://github.com/sgl-project/sglang/tree/main/.claude/skills/cookbook-add-model'
Download bundle ↓
main · a9fb1c3Scanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

Engine extension: add a new playground feature axis

Loaded on demand by the cookbook-add-model skill. Rare — adding a model cookbook is data-only and never needs this. The current 7 built-in axes (attention, moe, parsers, speculative, pdDisagg, hicache, hisparse) already cover the SGLang feature surface most cookbooks need.

Model-specific features are config DATA, not engine code. The axis handlers read options / flags / env / gating straight from config.playgroundFeatures, so a model-specific feature is added as data on an existing axis with NO engine edit — MegaMoE W4A4 is not its own axis, it's config data on moe (a megamoe backend option + a megamoeQuant sub-select). Reach for this file ONLY when a feature's shape — its title + the flag family it strips + its option/state model — is something no existing axis can express.

When you do extend, build a GENERIC primitive, never a model-named handler. The right unit is a reusable shape (e.g. "a titled single-select that strips a configurable flag family and splices the picked option's flags" — exactly the speculative handler's shape, minus its hardcoded title + --speculative-* strip list). Parameterize title, strip-prefixes, and options from config so the next model of that shape is pure config. Do NOT add a kvcache / <model-feature> handler that hardcodes one model's flag — that's the model-specific-code-in-the-engine anti-pattern this architecture exists to avoid. (Precedent: Nemotron3's "KV Cache DType" and Qwen3's mamba-cache select are the SAME single-select shape → one generic primitive serves both, then both are config.)

A new axis is backward-compatible — zero churn on existing configs. The runtime is opt-in per key: the apply/render loop does const fc = pgFeatures[axisId]; if (!fc) continue;, so any config that doesn't declare the key never sees the axis. And a model-specific axis does NOT join the opt-out "general axes ship on every cookbook" set (that set is an authoring convention for NEW configs, not a runtime default) — so review-pr won't flag existing pages for lacking it, and you never touch a merged config. Only the models that expose the control declare it. Touches _playground.jsx only.

For the per-model config/cells/MDX reference see authoring-reference.md.


3.1 Decide

Before touching the engine, confirm:

  • The feature cannot be expressed as data on an existing axis (a new MoE backend belongs in moe.backend.options; a new parser in parsers.items; a new spec preset in speculative.options). This is the common case — most "new features" are new options, not new shapes.
  • The shape is genuinely new (state model + strip pattern), AND you are adding it as a GENERIC config-parameterized axis (title / strip-prefixes / options all from config), not a one-model handler. If you'd hardcode a specific flag like --kv-cache-dtype, stop — generalize the shape instead.
  • The shape has a clean strip-prefix → emit-flag pattern.

If unsure, add it as data first (in one cookbook's config under an existing axis) before promoting it to a built-in axis.

3.2 Pick the axis id and state shape

The axis id is the key in both config.playgroundFeatures and the internal deltas object. Use camelCase, descriptive but short: mambaCache, attentionBackend, kvCacheDtype.

The state shape is whatever initState returns. Common shapes:

  • Single-select: a string sentinel (e.g. "disabled" / "current" / an option id).
  • Multi-toggle: {[itemId]: bool}.
  • Sub-knobs: {[knobId]: value | null}.
  • Compound (axis with its own internal sub-state, like PD-Disagg's {mode, ibDevice}): a plain object.

Pick ONE "inherit base" sentinel and document it in the handler comment.

3.3 Implement the handler

Add one entry to AXIS_HANDLERS in _playground.jsx. The handler owns everything: state init, apply (strip+insert), hidden-revert, AND the JSX render. Engine main loop iterates AXIS_HANDLERS and calls each method by name — adding a new axis is genuinely a one-place change.

Template:

// ---- Axis: <Title> ----------------------------------------------------
// <one-paragraph description of what this axis controls and why it
//  exists. Mention the SGLang feature it wraps and the strip/insert
//  policy.>
<axisId>: {
  initState: (fc) => /* initial state value */,

  // Called when base cell changes. Return new value if the picked option
  // is now hidden by a constraint; otherwise return value unchanged.
  // Disabled picks are intentionally NOT auto-reverted (soft warning).
  revertHidden: (value, fc, base, h) => {
    // ... return value or a new value
    return value;
  },

  // Pure function. Receives the current (flags, env) and returns the next
  // (flags, env). Do NOT mutate inputs. The `value` argument is whatever
  // initState returned. The `fc` argument is config.playgroundFeatures[axisId].
  // The `sel` argument is the current base cell selection. The `h`
  // argument is the helpers bundle (strip/insert primitives + anchors).
  apply: ({ flags, env, value, fc, sel, h, derived }) => {
    if (/* value is the inherit-base sentinel */) return { flags, env };
    flags = h.stripFlagsByFirstToken(flags, [/* prefixes this axis owns */]);
    if (/* an option is picked */) {
      flags = h.insertAfter(flags, h.ANCHOR_NEAR_<X>, [/* new flags */]);
      // or: flags = h.insertBeforeTail(flags, [/* new flags */]);
      // if the axis mutates env:
      // env = h.stripEnvByPrefix(env, fc.stripEnv || []);
      // env = [...env, /* additional env vars */];
    }
    return { flags, env };
  },

  // Optional: read the base cell's flag array back into the same shape
  // initState/apply use. Render shows this as the default selection
  // (dropdown option or checked chip) when the state slot is the inherit
  // sentinel — so the user sees the cell's actual --tp / MoE backend /
  // spec preset instead of an opaque "Auto." When derive returns a real
  // value, the inherit-sentinel option is hidden from the control. Apply
  // also receives the derived
  // value (as `derived`) and may use it as a no-op shortcut when the
  // user's pick matches base. Skip when your axis owns flags that never
  // appear in base cells (PD-Disagg / HiCache).
  // deriveFromBase: (cell, fc, h) => ({ ... }) | null,

  // Optional: hints for the renderer. Currently only pdDisagg uses this
  // to report its role banner. Omit if not needed.
  // getRenderHints: (value, fc) => ({ pdMode: ... }) | null,

  // Returns the axis card JSX. The outer div MUST have key={axisId} so
  // React can track it in the engine's map loop. Return null for
  // axis-level gating (e.g. HiSparse when the live PD mode isn't `decode`). Lay out as a single
  // compact horizontal row: title on the left, fields after.
  render: ({ axisId, value, setValue, fc, base, s, h, renderChip, renderSelect, derived }) => {
    if (/* axis-level gating fails */) return null;
    return (
      <div key={axisId} style={s.card}>
        <div style={s.compactRow}>
          <span style={s.axisTitle}>Axis Title</span>
          {/* For multi-option fields, use renderSelect(...) — the default.
              For on/off toggles or single-select chip groups, use
              renderChip instead (see "Control choice" in the conventions
              below). Read state from `value`; write via `setValue(next)`
              (replaces the whole axis slot). */}
          <span style={s.field}>
            <span style={s.fieldLabel}>Field</span>
            {renderSelect(value.slot, fc.entries, (v) =>
              setValue({ ...value, slot: v }), base)}
          </span>
        </div>
      </div>
    );
  },
},

Important conventions:

  • Insert the entry in the position you want it rendered. AXIS_HANDLERS is iterated in insertion order for both render and apply.
  • Use h.ANCHOR_NEAR_* constants for insertion. Add a new anchor to the helpers bundle if your axis needs to land somewhere new in the flag block.
  • Use lowercase HTML JSX tags only. Capitalized tags get rebound by Mintlify.
  • Inside render, read state via value (the slice for this axis). Write state via setValue(next) (replaces the whole slice). For compound axes, do setValue({ ...value, [k]: nextK }).
  • Layout: one s.compactRow per axis card, s.axisTitle for the leading label, one s.field per (label + input) pair.
  • Control choice — renderSelect vs renderChip:
    • renderSelect(current, entries, onPick, base, labelFor?, opts?) is the default compact control (a <select> dropdown). It filters hidden chips and disables greyed-out ones internally — no per-chip evaluateChip loop needed in the render body. Most axes use it (attention, moe, pdDisagg, hisparse, hicache). Pass { hideValues: [<sentinel>] } when your deriveFromBase resolved to a real value, so the inherit-sentinel ("Auto" / "Inherited" / "current") doesn't clutter the dropdown.
    • renderChip(label, current, value, onPick, { disabled?, disabledReason? }) renders a button instead of a dropdown row. Use it for a chip group when you want the options laid out as buttons. It serves two shapes:
      • Multi-toggle (Parsers) — one independent on/off chip per item; current is that item's effective bool, value is true, so the chip is "checked" when the item is on.
      • Single-select (Speculative) — a radio-style group; pass the group's effective value as current and each option's id as value, so exactly one chip is checked (current === value). Chip groups own their visibility/disable filtering: loop h.evaluateChip(opt, base) in the render body, skip c.hidden, filter the inherit-sentinel yourself when deriveFromBase resolved to a real value, and forward c.disabled / c.disableReason into renderChip's opts (this is what surfaces a disabled chip's tooltip, e.g. a "Coming soon" entry).
  • Selected chips use the same terracotta (#D45D44) as the Deploy panel's selected button, so both widgets read as one visual system. Don't introduce a per-axis accent color.
  • Default-from-base: if your axis can be read out of base cells' flags, implement deriveFromBase and have your render show the derived value when state is the sentinel (e.g. const eff = value.tp !== null ? value.tp : (derived && derived.tp)). This is what makes a fresh playground load show the user's actual recipe instead of "auto." Flag-parsing helpers on h: parseIntFlag, hasFlag, findFlagArg.
  • Avoid the in operator wrapped in unary (!(x in y)). Mintlify's AST walker crashes on it (TypeError: this[e] is not a function). Use obj.key === undefined or obj.id !== undefined instead. Bare if (key in obj) (no surrounding !) is fine.

3.4 Document the per-cookbook schema

Edit the file header in _playground.jsx to add your new axis to the "Recognised keys" list, with a one-line description of its schema. Optionally add a paragraph below explaining its strip/insert policy.

Update the §2.3 axis table in authoring-reference.md to list the new axis.

3.5 Migrate cookbooks that need it

For each cookbook that should expose this axis, add a playgroundFeatures.<axisId> entry to its config. Verify the chip group renders, options apply correctly, and the diff matches expectations.


Pitfalls (engine work)

Insertion anchor missesinsertAfter falls back to right-after --model-path if none of its anchor prefixes are present. If your axis emits flags that should land somewhere specific, include the most likely anchor prefixes in your call. Order doesn't matter (set semantics).

Conditional strips — Some axes strip ONLY when overridden (attention.tp, moe.backend (incl. the MegaMoE quant env), speculative). Others strip UNCONDITIONALLY whenever declared (parsers, pdDisagg, hicache). The header comment in AXIS_HANDLERS documents which policy each axis uses; follow the same pattern when adding a new axis. If unsure, prefer conditional strip — it preserves base behavior when the user does not opt in.

Closure of AXIS_HANDLERS — Inside a handler method, you can reference AXIS_HANDLERS.<otherAxis> for cross-handler calls (no built-in axis currently needs this, but it works because AXIS_HANDLERS is in lexical scope). Do NOT use this for general logic — it tightly couples handlers. Reserve it for one handler's helpers shared between its own render and revertHidden.


Review checklist for a new-axis PR

  • AXIS_HANDLERS is the ONLY place that mentions the new axis id (apart from per-cookbook config). No if (axisId === '<new>') branches anywhere in the engine.
  • initState is deterministic and idempotent (does not depend on the base cell).
  • apply is pure — does not mutate inputs.
  • revertHidden returns the same reference when nothing changed (avoids unnecessary re-renders).
  • render returns null when axis-level gating fails (whole card hidden) — does not render an empty placeholder.
  • render sets key={axisId} on its outer element.
  • No !(x in y) patterns introduced (Mintlify AST walker crashes).
  • File header lists the new axis in "Recognised keys".
  • The §2.3 table in authoring-reference.md lists the new axis.
  • One existing cookbook config is updated to consume the new axis, and visual verification shows the diff is correct.
Referenced from SKILL.md