Skip to content

Qwen3.8-27B + SGLang (RadixArk/MiaAI DGX Spark): System message must be at the beginning — working plugin coalesce #45055

Description

@pchmielewski1

Summary

OpenCode 1.18.23 still emits multiple role: "system" fragments to OpenAI-compatible backends. Serving Qwen3.8-27B with SGLang on NVIDIA DGX Spark using the stock HuggingFace chat template (strict: only messages[0] may be system) makes every agent turn fail with:

Bad Request: System message must be at the beginning.

The same OpenCode config / same short model id worked under vLLM. After switching the inference stack to the MiaAI / RadixArk SGLang-on-DGX-Spark recipe, OpenCode broke — the model weights are Qwen3.8, the engine + raw HF template are what changed.

Related: #15059, #16560, #20785, #20813 · PRs #15018, #16981, #23656.

Serving stack (keywords)

Deployed from / based on:

  • Repo: https://github.com/MiaAI-Lab/Qwen3.8-27B-SGLang-DGX-Spark
  • Cookbook: https://docs.sglang.io/cookbook/autoregressive/Qwen/Qwen3.8-27B
  • Weights (default QUANT=nvfp4): RadixArk/Qwen3.8-27B-NVFP4-BF16-LMHead
    (also: RadixArk/Qwen3.8-27B-NVFP4, Qwen/Qwen3.8-27B, Qwen/Qwen3.8-27B-FP8)
  • Docker image: lmsysorg/sglang:qwen38-27b
  • Engine: SGLang (not vLLM) — / returns "SGLang is running", /v1/models → owned_by: "sglang"
  • Hardware: NVIDIA DGX Spark / GB10 (aarch64)
  • Spec modes in that repo: DSpark / EAGLE-MTP / DFlash2 (we hit the same template guard regardless)
  • Reasoning / tools (server): --reasoning-parser qwen3, tool parser qwen3_coder
  • Context: native 262144
  • Default served name in MiaAI scripts: SERVED_MODEL_NAME=qwen3.8-27b-sglang
  • Our OpenCode alias (intentionally shortened to match older config): API model id qwen3.8-27b → OpenCode dgx/qwen3.8-27b

Live identity probes also reported e.g. model_type: qwen3_5, architecture Qwen3_5ForConditionalGeneration, SGLang version string like 0.0.0.dev0+qwen38.27b.….

Repro

Server (template):

# OK — single system at [0]
curl …/v1/chat/completions -d '{
  "model":"qwen3.8-27b",
  "messages":[{"role":"system","content":"Be brief."},{"role":"user","content":"Say OK"}]
}'

# 400 — system not first
# "System message must be at the beginning."

# 400 — agent-like second system after tools
# [system, user, assistant+tools, tool, system, user]

OpenCode (before workaround):

opencode run -m dgx/qwen3.8-27b 'Reply with exactly: PONG'
# → Error: Bad Request: System message must be at the beginning.

Workaround that works (plugin, no OpenCode fork)

Last plugin in plugin[] coalesces via experimental hooks (in-place mutate; reassign is a no-op):

  1. experimental.chat.system.transform — join all output.system[] with \n\n → one fragment
  2. experimental.chat.messages.transform — merge all system/developer messages → one messages[0]

Optional model config (documents intent; may be ignored until core lands):

"systemMessage": "single"

After plugin + restart:

opencode run -m dgx/qwen3.8-27b 'Reply with exactly: PONG'
# → PONG

Plugin sketch

export default async function QwenSingleSystem() {
  const mergeSystemFragments = (system) => {
    if (!Array.isArray(system) || system.length <= 1) return
    const merged = system
      .map((s) => (typeof s === "string" ? s : String(s ?? "")))
      .map((s) => s.trim())
      .filter(Boolean)
      .join("\n\n")
    system.splice(0, system.length, merged || "")
  }

  const collapseMessageSystems = (messages) => {
    if (!Array.isArray(messages) || messages.length === 0) return
    const systemIdx = []
    for (let i = 0; i < messages.length; i++) {
      const role = messages[i]?.info?.role
      if (role === "system" || role === "developer") systemIdx.push(i)
    }
    if (systemIdx.length <= 1) {
      if (systemIdx.length === 1 && systemIdx[0] !== 0) {
        const [sys] = messages.splice(systemIdx[0], 1)
        messages.unshift(sys)
      }
      return
    }
    const texts = []
    for (const i of systemIdx) {
      for (const p of messages[i]?.parts || []) {
        if (p?.type === "text" && p.text) texts.push(String(p.text).trim())
      }
    }
    const mergedText = texts.filter(Boolean).join("\n\n")
    for (let k = systemIdx.length - 1; k >= 0; k--) messages.splice(systemIdx[k], 1)
    messages.unshift({
      info: { role: "system" },
      parts: mergedText ? [{ type: "text", text: mergedText }] : [],
    })
  }

  return {
    "experimental.chat.system.transform": async (_input, output) => {
      mergeSystemFragments(output.system)
    },
    "experimental.chat.messages.transform": async (_input, output) => {
      collapseMessageSystems(output.messages)
    },
  }
}

Ask

Please land a core equivalent (systemMessage: "single" / merge before provider send for openai-compatible / non-Anthropic), so SGLang + raw HF Qwen3.8 (RadixArk NVFP4 on DGX Spark, lmsysorg/sglang:qwen38-27b) works without a local plugin.

Happy to test nightlies against this stack.

Note

We will drop the plugin when upstream merge ships and stays stable. If this coalesce causes context loss / instability, we will revert and report.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions